feat: 增强SaaS后端功能与安全性

refactor: 重构数据库连接使用PostgreSQL替代SQLite
feat(auth): 增加JWT验证的audience和issuer检查
feat(crypto): 添加AES-256-GCM字段加密支持
feat(api): 集成utoipa实现OpenAPI文档
fix(admin): 修复配置项表单验证逻辑
style: 统一代码格式与类型定义
docs: 更新技术栈文档说明PostgreSQL
This commit is contained in:
iven
2026-03-31 00:12:53 +08:00
parent 4d8d560d1f
commit 44256a511c
177 changed files with 9731 additions and 948 deletions

View File

@@ -102,8 +102,8 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
if (direction === 'local-to-saas' && localModels.length > 0) {
// Push local models as config items
for (const model of localModels) {
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
if (exists && !selectedKeys.has(model.id)) continue;
const existingItem = saasConfigs.find((c) => c.key_path === `models.${model.id}`);
if (existingItem && !selectedKeys.has(model.id)) continue;
const body = {
category: 'model',
@@ -114,8 +114,8 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
description: `从桌面端同步: ${model.name}`,
};
if (exists) {
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${exists}`, body);
if (existingItem) {
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${existingItem.id}`, body);
} else {
await saasClient.request<unknown>('POST', '/api/v1/config/items', body);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
import { saasClient, type SaaSAccountInfo, type SaaSModelInfo } from '../../lib/saas-client';
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useSaaSStore } from '../../store/saasStore';

View File

@@ -6,18 +6,7 @@ import { useConfigStore } from '../../store/configStore';
import { useChatStore } from '../../store/chatStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X, Zap, Check } from 'lucide-react';
// 自定义模型数据结构
interface CustomModel {
id: string;
name: string;
provider: string;
apiKey?: string;
apiProtocol: 'openai' | 'anthropic' | 'custom';
baseUrl?: string;
isDefault?: boolean;
createdAt: string;
}
import type { CustomModel, CustomModelApiProtocol } from '../../types/config';
// Embedding 配置数据结构
interface EmbeddingConfig {
@@ -140,7 +129,7 @@ export function ModelsAPI() {
modelId: 'glm-4-flash',
displayName: '',
apiKey: '',
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
apiProtocol: 'openai' as CustomModelApiProtocol,
baseUrl: '',
});
@@ -650,7 +639,7 @@ export function ModelsAPI() {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API </label>
<select
value={formData.apiProtocol}
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as CustomModelApiProtocol })}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="openai">OpenAI</option>

View File

@@ -455,10 +455,24 @@ export function clearSecurityLog(): void {
}
/**
* Generate a random API key for testing
* WARNING: Only use for testing purposes
* Generate a random API key for testing.
*
* @internal This function is intended solely for automated tests and
* development tooling. It must never be called in production
* builds because generated keys are not cryptographically secure
* and should never be used to authenticate against real services.
*
* @param type - The API key type to generate a test key for
* @returns A random API key that passes format validation for the given type
* @throws {Error} If called outside of a development or test environment
*/
export function generateTestApiKey(type: ApiKeyType): string {
if (import.meta.env?.DEV !== true && import.meta.env?.MODE !== 'test') {
throw new Error(
'[Security] generateTestApiKey may only be called in development or test environments'
);
}
const rules = KEY_VALIDATION_RULES[type];
const length = rules.minLength + 10;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

View File

@@ -37,13 +37,17 @@ export {
DEFAULT_GATEWAY_URL,
REST_API_URL,
FALLBACK_GATEWAY_URLS,
ZCLAW_GRPC_PORT,
ZCLAW_LEGACY_PORT,
normalizeGatewayUrl,
isLocalhost,
getStoredGatewayUrl,
setStoredGatewayUrl,
getStoredGatewayToken,
setStoredGatewayToken,
detectConnectionMode,
} from './gateway-storage';
export type { ConnectionMode } from './gateway-storage';
// === Internal imports ===
import type {
@@ -69,6 +73,7 @@ import {
isLocalhost,
getStoredGatewayUrl,
getStoredGatewayToken,
detectConnectionMode,
} from './gateway-storage';
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
@@ -273,8 +278,8 @@ export class GatewayClient {
return Promise.resolve();
}
// Check if URL is for ZCLAW (port 4200 or 50051) - use REST mode
if (this.url.includes(':4200') || this.url.includes(':50051')) {
// Check if URL is for ZCLAW (known kernel ports) - use REST mode
if (detectConnectionMode(this.url) === 'rest') {
return this.connectRest();
}

View File

@@ -40,15 +40,47 @@ export function isLocalhost(url: string): boolean {
}
}
// === Port Constants ===
/** Default gRPC/HTTP port used by the ZCLAW kernel */
export const ZCLAW_GRPC_PORT = 50051;
/** Legacy/alternative port used in development or older configurations */
export const ZCLAW_LEGACY_PORT = 4200;
// === Connection Mode ===
/**
* Determines how the client connects to the ZCLAW gateway.
* - `rest`: Kernel exposes an HTTP REST API (gRPC-gateway). Used when the
* URL contains a known kernel port.
* - `ws`: Direct WebSocket connection to the kernel.
*/
export type ConnectionMode = 'rest' | 'ws';
/**
* Decide the connection mode based on the gateway URL.
*
* When the URL contains a known kernel port (gRPC or legacy), the client
* routes requests through the REST adapter instead of opening a raw
* WebSocket.
*/
export function detectConnectionMode(url: string): ConnectionMode {
if (url.includes(`:${ZCLAW_GRPC_PORT}`) || url.includes(`:${ZCLAW_LEGACY_PORT}`)) {
return 'rest';
}
return 'ws';
}
// === URL Constants ===
// ZCLAW endpoints (port 50051 - actual running port)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:${ZCLAW_GRPC_PORT}/ws`;
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
export const FALLBACK_GATEWAY_URLS = [
DEFAULT_GATEWAY_URL,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:${ZCLAW_LEGACY_PORT}/ws`,
];
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';

View File

@@ -6,8 +6,13 @@
*
* API base path: /api/v1/...
* Auth: Bearer token in Authorization header
*
* Security: JWT token is stored via secureStorage (OS keychain or encrypted localStorage).
* URL, account info, and connection mode remain in plain localStorage (non-sensitive).
*/
import { secureStorage } from './secure-storage';
// === Storage Keys ===
const SAASTOKEN_KEY = 'zclaw-saas-token';
@@ -146,6 +151,55 @@ export interface ConfigSyncResult {
skipped: number;
}
// === JWT Helpers ===
/**
* Decode a JWT payload without verifying the signature.
* Returns the parsed JSON payload, or null if the token is malformed.
*/
export function decodeJwtPayload<T = Record<string, unknown>>(token: string): T | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
// JWT payload is Base64Url-encoded
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
return JSON.parse(json) as T;
} catch {
return null;
}
}
/** JWT payload shape we care about */
interface JwtPayload {
exp?: number;
iat?: number;
sub?: string;
}
/**
* Calculate the delay (ms) until 80% of the token's lifetime has elapsed.
* This is the ideal moment to trigger a proactive refresh.
* Returns null if the token has no exp claim or is already past 80% lifetime.
*/
export function getRefreshDelay(exp: number): number | null {
const now = Math.floor(Date.now() / 1000);
const totalLifetime = exp - now;
if (totalLifetime <= 0) return null; // already expired
// Refresh at 80% of the token's remaining lifetime
const refreshAt = now + Math.floor(totalLifetime * 0.8);
const delayMs = (refreshAt - now) * 1000;
// Minimum 5-second guard to avoid hammering the endpoint
return delayMs > 5000 ? delayMs : 5000;
}
// === Error Class ===
export class SaaSApiError extends Error {
@@ -168,16 +222,35 @@ export interface SaaSSession {
}
/**
* Load a persisted SaaS session from localStorage.
* Read a value from localStorage with error handling.
*/
function readLegacyLocalStorage(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
/**
* Load a persisted SaaS session using secure storage for the JWT token.
* Falls back to legacy localStorage if secureStorage has no token (migration).
* Returns null if no valid session exists.
*/
export function loadSaaSSession(): SaaSSession | null {
export async function loadSaaSSessionAsync(): Promise<SaaSSession | null> {
try {
const token = localStorage.getItem(SAASTOKEN_KEY);
const saasUrl = localStorage.getItem(SAASURL_KEY);
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
// Try secure storage first (keychain or encrypted localStorage)
const token = await secureStorage.get(SAASTOKEN_KEY);
if (!token || !saasUrl) {
// Migration: if secureStorage is empty, try legacy localStorage
const legacyToken = !token ? readLegacyLocalStorage(SAASTOKEN_KEY) : null;
const saasUrl = readLegacyLocalStorage(SAASURL_KEY);
const accountRaw = readLegacyLocalStorage(SAASACCOUNT_KEY);
const effectiveToken = token || legacyToken;
if (!effectiveToken || !saasUrl) {
return null;
}
@@ -185,19 +258,30 @@ export function loadSaaSSession(): SaaSSession | null {
? (JSON.parse(accountRaw) as SaaSAccountInfo)
: null;
return { token, account, saasUrl };
// If we found a legacy token in localStorage, migrate it to secure storage
if (legacyToken && !token) {
await secureStorage.set(SAASTOKEN_KEY, legacyToken);
// Remove plaintext token from localStorage after migration
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
}
return { token: effectiveToken, account, saasUrl };
} catch {
// Corrupted data - clear all
clearSaaSSession();
await clearSaaSSessionAsync();
return null;
}
}
/**
* Persist a SaaS session to localStorage.
* Persist a SaaS session using secure storage for the JWT token.
* URL and account info remain in localStorage (non-sensitive).
*/
export function saveSaaSSession(session: SaaSSession): void {
localStorage.setItem(SAASTOKEN_KEY, session.token);
export async function saveSaaSSessionAsync(session: SaaSSession): Promise<void> {
await secureStorage.set(SAASTOKEN_KEY, session.token);
// Remove legacy plaintext token from localStorage
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
localStorage.setItem(SAASURL_KEY, session.saasUrl);
if (session.account) {
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
@@ -205,16 +289,18 @@ export function saveSaaSSession(session: SaaSSession): void {
}
/**
* Clear the persisted SaaS session from localStorage.
* Clear the persisted SaaS session from both secure storage and localStorage.
*/
export function clearSaaSSession(): void {
localStorage.removeItem(SAASTOKEN_KEY);
localStorage.removeItem(SAASURL_KEY);
localStorage.removeItem(SAASACCOUNT_KEY);
export async function clearSaaSSessionAsync(): Promise<void> {
await secureStorage.delete(SAASTOKEN_KEY);
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
try { localStorage.removeItem(SAASURL_KEY); } catch { /* ignore */ }
try { localStorage.removeItem(SAASACCOUNT_KEY); } catch { /* ignore */ }
}
/**
* Persist the connection mode to localStorage.
* Connection mode is non-sensitive -- no need for secure storage.
*/
export function saveConnectionMode(mode: string): void {
localStorage.setItem(SAASMODE_KEY, mode);
@@ -230,9 +316,15 @@ export function loadConnectionMode(): string | null {
// === Client Implementation ===
/** Callback invoked when token refresh fails and the session should be terminated. */
export type OnSessionExpired = () => void;
export class SaaSClient {
private baseUrl: string;
private token: string | null = null;
private refreshTimerId: ReturnType<typeof setTimeout> | null = null;
private visibilityHandler: (() => void) | null = null;
private onSessionExpired: OnSessionExpired | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
@@ -248,9 +340,22 @@ export class SaaSClient {
return this.baseUrl;
}
/** Set or clear the auth token */
/** Set or clear the auth token. Automatically schedules a proactive refresh. */
setToken(token: string | null): void {
this.token = token;
if (token) {
this.scheduleTokenRefresh();
} else {
this.cancelTokenRefresh();
}
}
/**
* Register a callback invoked when the proactive token refresh fails.
* The caller should use this to trigger a logout/redirect flow.
*/
setOnSessionExpired(handler: OnSessionExpired): void {
this.onSessionExpired = handler;
}
/** Check if the client has an auth token */
@@ -258,6 +363,102 @@ export class SaaSClient {
return !!this.token;
}
/**
* Schedule a proactive token refresh at 80% of the token's remaining lifetime.
* Also registers a visibilitychange listener to re-check when the tab regains focus.
*/
scheduleTokenRefresh(): void {
this.cancelTokenRefresh();
if (!this.token) return;
const payload = decodeJwtPayload<JwtPayload>(this.token);
if (!payload?.exp) return;
const delay = getRefreshDelay(payload.exp);
if (delay === null) {
// Token already expired or too close -- attempt immediate refresh
this.attemptTokenRefresh();
return;
}
this.refreshTimerId = setTimeout(() => {
this.attemptTokenRefresh();
}, delay);
// When the tab becomes visible again, check if we should refresh sooner
if (typeof document !== 'undefined' && !this.visibilityHandler) {
this.visibilityHandler = () => {
if (document.visibilityState === 'visible') {
this.checkAndRefreshToken();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
}
}
/**
* Cancel any pending token refresh timer and remove the visibility listener.
*/
cancelTokenRefresh(): void {
if (this.refreshTimerId !== null) {
clearTimeout(this.refreshTimerId);
this.refreshTimerId = null;
}
if (this.visibilityHandler !== null && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
}
/**
* Check if the current token is close to expiry and refresh if needed.
* Called on visibility change to handle clock skew / long background tabs.
*/
private checkAndRefreshToken(): void {
if (!this.token) return;
const payload = decodeJwtPayload<JwtPayload>(this.token);
if (!payload?.exp) return;
const now = Math.floor(Date.now() / 1000);
const remaining = payload.exp - now;
// If less than 20% of lifetime remains, refresh now
if (remaining <= 0) {
this.attemptTokenRefresh();
return;
}
// If the scheduled refresh is more than 60s away and we're within 80%, do it now
const delay = getRefreshDelay(payload.exp);
if (delay !== null && delay < 60_000) {
this.attemptTokenRefresh();
}
}
/**
* Attempt to refresh the token. On failure, invoke the session-expired callback.
* Persists the new token via secureStorage.
*/
private attemptTokenRefresh(): Promise<void> {
return this.refreshToken()
.then(async (newToken) => {
// Persist the new token to secure storage
const existing = await loadSaaSSessionAsync();
if (existing) {
await saveSaaSSessionAsync({ ...existing, token: newToken });
}
})
.catch(() => {
// Refresh failed -- notify the app to log out
this.cancelTokenRefresh();
if (this.onSessionExpired) {
this.onSessionExpired();
}
});
}
// --- Core HTTP ---
/** Track whether the server appears reachable */
@@ -436,7 +637,7 @@ export class SaaSClient {
/**
* Register or update this device with the SaaS backend.
* Uses UPSERT semantics same (account, device_id) updates last_seen_at.
* Uses UPSERT semantics -- same (account, device_id) updates last_seen_at.
*/
async registerDevice(params: {
device_id: string;

View File

@@ -37,18 +37,9 @@ const log = createLogger('ConnectionStore');
// === Custom Models Helpers ===
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
import type { CustomModel } from '../types/config';
interface CustomModel {
id: string;
name: string;
provider: string;
apiKey?: string;
apiProtocol: 'openai' | 'anthropic' | 'custom';
baseUrl?: string;
isDefault?: boolean;
createdAt: string;
}
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
/**
* Get custom models from localStorage
@@ -218,8 +209,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
const savedMode = localStorage.getItem('zclaw-connection-mode');
if (savedMode === 'saas') {
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
const session = loadSaaSSession();
const { loadSaaSSessionAsync, saasClient } = await import('../lib/saas-client');
const session = await loadSaaSSessionAsync();
if (!session || !session.token || !session.saasUrl) {
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');

View File

@@ -2,8 +2,8 @@
* SaaS Store - SaaS Platform Connection State Management
*
* Manages SaaS login state, account info, connection mode,
* and available models. Persists auth state to localStorage
* via saas-client helpers.
* and available models. Persists auth token via secureStorage
* (OS keychain or encrypted localStorage) for security.
*
* Connection modes:
* - 'tauri': Local Kernel via Tauri (default)
@@ -15,9 +15,9 @@ import { create } from 'zustand';
import {
saasClient,
SaaSApiError,
loadSaaSSession,
saveSaaSSession,
clearSaaSSession,
loadSaaSSessionAsync,
saveSaaSSessionAsync,
clearSaaSSessionAsync,
saveConnectionMode,
loadConnectionMode,
type SaaSAccountInfo,
@@ -64,12 +64,12 @@ export interface SaaSActionsSlice {
login: (saasUrl: string, username: string, password: string) => Promise<void>;
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
restoreSession: () => Promise<void>;
setupTotp: () => Promise<TotpSetupResponse>;
verifyTotp: (code: string) => Promise<void>;
disableTotp: (password: string) => Promise<void>;
@@ -85,33 +85,56 @@ const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
// === Helpers ===
/** Determine the initial connection mode from persisted state */
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
function resolveInitialMode(hasSession: boolean): ConnectionMode {
const persistedMode = loadConnectionMode();
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
return persistedMode;
}
return session ? 'saas' : 'tauri';
return hasSession ? 'saas' : 'tauri';
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Restore session from localStorage on init
const session = loadSaaSSession();
const initialMode = resolveInitialMode(session);
// Determine initial connection mode synchronously from localStorage.
// Session token will be loaded asynchronously via restoreSession().
const persistedMode = loadConnectionMode();
const hasSession = persistedMode === 'saas';
const initialMode = resolveInitialMode(hasSession);
// If session exists, configure the singleton client
if (session) {
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
}
// Kick off async session restoration immediately.
// The store initializes with a "potentially logged in" state based on
// the connection mode, and restoreSession() will either hydrate the token
// or clear the session if secure storage has no token.
loadSaaSSessionAsync().then((session) => {
if (session) {
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
set({
isLoggedIn: true,
account: session.account,
saasUrl: session.saasUrl,
authToken: session.token,
connectionMode: resolveInitialMode(true),
});
// Fetch models in background after async restore
get().fetchAvailableModels().catch(() => {});
} else if (persistedMode === 'saas') {
// Connection mode was 'saas' but no token found -- reset to tauri
saveConnectionMode('tauri');
set({ connectionMode: 'tauri' });
}
}).catch(() => {
// secureStorage read failed -- keep defaults
});
return {
// === Initial State ===
isLoggedIn: session !== null,
account: session?.account ?? null,
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: session?.token ?? null,
// Session data will be hydrated by the async restoreSession above.
isLoggedIn: hasSession,
account: null,
saasUrl: DEFAULT_SAAS_URL,
authToken: null,
connectionMode: initialMode,
availableModels: [],
isLoading: false,
@@ -144,13 +167,13 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
// Persist session
// Persist session securely
const sessionData = {
token: loginData.token,
account: loginData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
await saveSaaSSessionAsync(sessionData);
saveConnectionMode('saas');
set({
@@ -212,7 +235,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
account: loginData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
await saveSaaSSessionAsync(sessionData);
saveConnectionMode('saas');
set({
@@ -273,7 +296,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
account: registerData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
await saveSaaSSessionAsync(sessionData);
saveConnectionMode('saas');
set({
@@ -305,9 +328,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
logout: () => {
logout: async () => {
saasClient.setToken(null);
clearSaaSSession();
await clearSaaSSessionAsync();
saveConnectionMode('tauri');
set({
@@ -393,8 +416,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
set({ error: null });
},
restoreSession: () => {
const restored = loadSaaSSession();
restoreSession: async () => {
const restored = await loadSaaSSessionAsync();
if (restored) {
saasClient.setBaseUrl(restored.saasUrl);
saasClient.setToken(restored.token);
@@ -430,7 +453,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
await saveSaaSSessionAsync({ token: authToken, account, saasUrl });
}
set({ totpSetupData: null, isLoading: false, account });
} catch (err: unknown) {
@@ -448,7 +471,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
await saveSaaSSessionAsync({ token: authToken, account, saasUrl });
}
set({ isLoading: false, account });
} catch (err: unknown) {

View File

@@ -571,3 +571,35 @@ export interface ConfigFileMetadata {
/** Whether the file has unresolved env vars */
hasUnresolvedEnvVars?: boolean;
}
// ============================================================
// Custom Model Types
// ============================================================
/**
* API protocol supported by a custom model provider.
*/
export type CustomModelApiProtocol = 'openai' | 'anthropic' | 'custom';
/**
* User-defined custom model configuration.
* Used by the model settings UI and the connection store.
*/
export interface CustomModel {
/** Unique identifier */
id: string;
/** Human-readable model name */
name: string;
/** Provider / vendor name */
provider: string;
/** API key (optional, stored separately in secure storage) */
apiKey?: string;
/** Which API protocol this provider speaks */
apiProtocol: CustomModelApiProtocol;
/** Base URL for the provider API (optional) */
baseUrl?: string;
/** Whether this model is the user's default */
isDefault?: boolean;
/** ISO-8601 timestamp of when this model was added */
createdAt: string;
}

View File

@@ -141,6 +141,12 @@ export type {
AutomationItem,
} from './automation';
// Custom Model Types
export type {
CustomModel,
CustomModelApiProtocol,
} from './config';
// Automation Constants and Functions
export {
HAND_CATEGORY_MAP,