/** * ZCLAW SaaS Client * * Typed HTTP client for the ZCLAW SaaS backend API (v1). * Handles authentication, model listing, chat relay, and config management. * * API base path: /api/v1/... * Auth: HttpOnly cookie (primary) + Bearer token fallback * * Security: Tokens are NO LONGER persisted to localStorage. * The backend sets HttpOnly cookies on login/register/refresh. * On page reload, cookie-based auth is verified via GET /api/v1/auth/me. * * Architecture: This file is the main entry point and thin shell. * Types, errors, session helpers, and API methods are in sub-modules: * - saas-types.ts — all type/interface definitions * - saas-errors.ts — SaaSApiError class * - saas-session.ts — session persistence (load/save/clear) * - saas-auth.ts — login/register/TOTP methods (mixin) * - saas-relay.ts — relay tasks, chat completion, usage (mixin) * - saas-prompt.ts — prompt OTA methods (mixin) * - saas-telemetry.ts — telemetry reporting methods (mixin) */ // === Re-export everything from sub-modules === export type { SaaSAccountInfo, SaaSModelInfo, SaaSConfigItem, SaaSErrorResponse, SaaSLoginResponse, SaaSRefreshResponse, TotpSetupResponse, TotpResultResponse, DeviceInfo, RelayTaskInfo, SyncConfigRequest, ConfigDiffItem, ConfigDiffResponse, ConfigSyncResult, PaginatedResponse, PromptTemplateInfo, PromptVersionInfo, PromptVariable, PromptCheckResult, PromptUpdatePayload, ProviderInfo, CreateProviderRequest, UpdateProviderRequest, ModelInfo, CreateModelRequest, UpdateModelRequest, AccountApiKeyInfo, CreateApiKeyRequest, UsageStats, AccountPublic, UpdateAccountRequest, TokenInfo, CreateTokenRequest, OperationLogInfo, DashboardStats, RoleInfo, CreateRoleRequest, UpdateRoleRequest, PermissionTemplate, CreateTemplateRequest, AgentTemplateAvailable, AgentTemplateFull, AgentConfigFromTemplate, } from './saas-types'; export { SaaSApiError } from './saas-errors'; export type { SaaSSession } from './saas-session'; export { loadSaaSSession, loadSaaSSessionSync, saveSaaSSession, clearSaaSSession, saveConnectionMode, loadConnectionMode, } from './saas-session'; // === Imports for the class implementation === import type { SaaSAccountInfo, SaaSLoginResponse, TotpSetupResponse, TotpResultResponse, SaaSModelInfo, SaaSConfigItem, DeviceInfo, SyncConfigRequest, ConfigDiffResponse, ConfigSyncResult, SaaSErrorResponse, RelayTaskInfo, UsageStats, PromptCheckResult, PromptTemplateInfo, PromptVersionInfo, PaginatedResponse, AgentTemplateAvailable, AgentTemplateFull, AgentConfigFromTemplate, } from './saas-types'; import { SaaSApiError } from './saas-errors'; import { clearSaaSSession } from './saas-session'; import { createLogger } from './logger'; const saasLog = createLogger('saas-client'); import { installAuthMethods } from './saas-auth'; import { installRelayMethods } from './saas-relay'; import { installPromptMethods } from './saas-prompt'; import { installTelemetryMethods } from './saas-telemetry'; import { installBillingMethods } from './saas-billing'; // === Client Implementation === export class SaaSClient { private baseUrl: string; private token: string | null = null; /** * Refresh mutex: shared Promise to prevent concurrent token refresh. * When multiple requests hit 401 simultaneously, they all await the same * refresh Promise instead of triggering N parallel refresh calls. */ private _refreshPromise: Promise | null = null; /** * Thread-safe token refresh — coalesces concurrent refresh attempts into one. * First caller triggers the actual refresh; subsequent callers await the same Promise. */ async refreshMutex(): Promise { if (this._refreshPromise) return this._refreshPromise; this._refreshPromise = this.refreshToken().finally(() => { this._refreshPromise = null; }); return this._refreshPromise; } constructor(baseUrl: string) { this.baseUrl = baseUrl.replace(/\/+$/, ''); } /** Update the base URL (e.g. when user changes server address) */ setBaseUrl(url: string): void { this.baseUrl = url.replace(/\/+$/, ''); } /** Get the current base URL */ getBaseUrl(): string { return this.baseUrl; } /** Set or clear the auth token (in-memory only, never persisted) */ setToken(token: string | null): void { this.token = token; } /** Check if the client is authenticated (token in memory or cookie-based) */ isAuthenticated(): boolean { return !!this.token || this._cookieAuth; } /** Track cookie-based auth state (page reload) */ private _cookieAuth: boolean = false; /** * Attempt to restore auth state from HttpOnly cookie. * Called on page reload when no token is in memory. * Returns account info if cookie is valid, null otherwise. */ async restoreFromCookie(): Promise { try { const account = await this.me(); this._cookieAuth = true; return account; } catch (e) { saasLog.debug('Cookie auth restore failed', { error: e }); this._cookieAuth = false; return null; } } /** Check if a path is an auth endpoint (avoid infinite refresh loop) */ private _isAuthEndpoint(path: string): boolean { return path.includes('/auth/login') || path.includes('/auth/register') || path.includes('/auth/refresh'); } // --- Core HTTP --- /** Track whether the server appears reachable */ private _serverReachable: boolean = true; /** Check if the SaaS server was last known to be reachable */ isServerReachable(): boolean { return this._serverReachable; } /** * Make an authenticated request with automatic retry on transient failures. * Retries up to 2 times with exponential backoff (1s, 2s). * Throws SaaSApiError on non-ok responses. */ public async request( method: string, path: string, body?: unknown, timeoutMs = 15000, _isRefreshRetry = false, ): Promise { const maxRetries = 2; const baseDelay = 1000; for (let attempt = 0; attempt <= maxRetries; attempt++) { const headers: Record = { 'Content-Type': 'application/json', }; // Bearer token as fallback — primary auth is HttpOnly cookie if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } try { const response = await fetch(`${this.baseUrl}${path}`, { method, headers, credentials: 'include', // Send HttpOnly cookies body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), }); this._serverReachable = true; // 401: 尝试刷新 Token 后重试 (防止递归) if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) { try { const newToken = await this.refreshMutex(); if (newToken) { return this.request(method, path, body, timeoutMs, true); } } catch (refreshErr) { // Token refresh failed — clear session and trigger logout clearSaaSSession().catch(e => saasLog.debug('Failed to clear SaaS session on refresh failure', { error: e })); // async cleanup, fire-and-forget localStorage.removeItem('zclaw-connection-mode'); throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录'); } throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录'); } if (!response.ok) { const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null; throw new SaaSApiError( response.status, errorBody?.error || 'UNKNOWN', errorBody?.message || `请求失败 (${response.status})`, ); } // 204 No Content if (response.status === 204) { return undefined as T; } return response.json() as Promise; } catch (err: unknown) { const isNetworkError = err instanceof TypeError && (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')); if (isNetworkError && attempt < maxRetries) { this._serverReachable = false; const delay = baseDelay * Math.pow(2, attempt); await new Promise((r) => setTimeout(r, delay)); continue; } this._serverReachable = false; if (err instanceof SaaSApiError) throw err; throw new SaaSApiError(0, 'NETWORK_ERROR', `网络错误: ${err instanceof Error ? err.message : String(err)}`); } } // Unreachable, but TypeScript needs it throw new SaaSApiError(0, 'UNKNOWN', '请求失败'); } // --- Health --- /** * Quick connectivity check against the SaaS backend. */ async healthCheck(): Promise { try { await this.request('GET', '/api/health', undefined, 5000); return true; } catch (e) { saasLog.debug('Health check failed', { error: e }); return false; } } // --- Device Endpoints --- /** * Register or update this device with the SaaS backend. * Uses UPSERT semantics — same (account, device_id) updates last_seen_at. */ async registerDevice(params: { device_id: string; device_name?: string; platform?: string; app_version?: string; }): Promise { await this.request('POST', '/api/v1/devices/register', params); } /** * Send a heartbeat to indicate the device is still active. * Also sends platform and app_version so the backend can detect client upgrades. */ async deviceHeartbeat(deviceId: string): Promise { const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; await this.request('POST', '/api/v1/devices/heartbeat', { device_id: deviceId, platform: typeof navigator !== 'undefined' ? navigator.platform : undefined, app_version: appVersion, }); } /** * List devices registered for the current account. */ async listDevices(): Promise { const res = await this.request<{ items: DeviceInfo[] }>('GET', '/api/v1/devices'); return res.items; } // --- Model Endpoints --- /** * List available models for relay. * Only returns enabled models from enabled providers. */ async listModels(): Promise { return this.request('GET', '/api/v1/relay/models'); } // --- Config Endpoints --- /** * List config items, optionally filtered by category. */ async listConfig(category?: string): Promise { const qs = category ? `?category=${encodeURIComponent(category)}` : ''; const res = await this.request<{ items: SaaSConfigItem[] }>('GET', `/api/v1/config/items${qs}`); return res.items; } /** Compute config diff between client and SaaS (read-only) */ async computeConfigDiff(request: SyncConfigRequest): Promise { return this.request('POST', '/api/v1/config/diff', request); } /** Sync config from client to SaaS (push) or merge */ async syncConfig(request: SyncConfigRequest): Promise { return this.request('POST', '/api/v1/config/sync', request); } /** * Pull all config items from SaaS (for startup auto-sync). * Returns configs updated since the given timestamp, or all if since is omitted. */ async pullConfig(since?: string): Promise<{ configs: Array<{ key: string category: string value: string | null value_type: string default: string | null updated_at: string }> pulled_at: string }> { const qs = since ? `?since=${encodeURIComponent(since)}` : ''; return this.request('GET', '/api/v1/config/pull' + qs); } // --- Agent Template Endpoints --- /** * List available agent templates (lightweight). * Used for template selection during onboarding. */ async fetchAvailableTemplates(): Promise { return this.request('GET', '/api/v1/agent-templates/available'); } /** * Get full template details by ID. * Returns all fields needed to create an agent from template. */ async fetchTemplateFull(id: string): Promise { return this.request('GET', `/api/v1/agent-templates/${id}/full`); } // --- Template Assignment --- /** * Assign a template to the current account. * Records the user's industry choice for onboarding flow control. */ async assignTemplate(templateId: string): Promise { return this.request('POST', '/api/v1/accounts/me/assign-template', { template_id: templateId, }); } /** * Get the template currently assigned to the account. * Returns null if no template is assigned. */ async getAssignedTemplate(): Promise { return this.request('GET', '/api/v1/accounts/me/assigned-template'); } /** * Unassign the current template from the account. */ async unassignTemplate(): Promise { await this.request('DELETE', '/api/v1/accounts/me/assigned-template'); } /** * Create an agent configuration from a template. * Merges capabilities into tools, applies default model fallback. */ async createAgentFromTemplate(templateId: string): Promise { return this.request('POST', `/api/v1/agent-templates/${templateId}/create-agent`); } } // === Install mixin methods === installAuthMethods(SaaSClient); installRelayMethods(SaaSClient); installPromptMethods(SaaSClient); installTelemetryMethods(SaaSClient); installBillingMethods(SaaSClient); // === API Method Type Declarations === // These methods are installed at runtime by installXxxMethods() in saas-*.ts. // We declare them here via interface merging so TypeScript knows they exist on SaaSClient. export interface SaaSClient { // --- Auth (saas-auth.ts) --- login(username: string, password: string, totpCode?: string): Promise; register(data: { username: string; email: string; password: string; display_name?: string }): Promise; me(): Promise; refreshToken(): Promise; changePassword(oldPassword: string, newPassword: string): Promise; setupTotp(): Promise; verifyTotp(code: string): Promise; disableTotp(password: string): Promise; // --- Relay (saas-relay.ts) --- listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise; getRelayTask(taskId: string): Promise; retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }>; chatCompletion(body: unknown, signal?: AbortSignal): Promise; getUsage(params?: { from?: string; to?: string; provider_id?: string; model_id?: string }): Promise; // --- Prompt OTA (saas-prompt.ts) --- checkPromptUpdates(deviceId: string, currentVersions: Record): Promise; listPrompts(params?: { category?: string; source?: string; status?: string; page?: number; page_size?: number }): Promise>; getPrompt(name: string): Promise; listPromptVersions(name: string): Promise; getPromptVersion(name: string, version: number): Promise; // --- Telemetry (saas-telemetry.ts) --- reportTelemetry(data: { device_id: string; app_version: string; entries: Array<{ model_id: string; input_tokens: number; output_tokens: number; latency_ms?: number; success: boolean; error_type?: string; timestamp: string; connection_mode: string; }>; }): Promise<{ accepted: number; rejected: number }>; reportAuditSummary(data: { device_id: string; entries: Array<{ action: string; target: string; result: string; timestamp: string; }>; }): Promise<{ accepted: number; total: number }>; // --- Billing (saas-billing.ts) --- incrementUsageDimension(dimension: string, count?: number): Promise; reportUsageFireAndForget(dimension: string, count?: number): void; } // === Singleton === /** * Global SaaS client singleton. * Initialized with a default URL; the URL and token are updated on login. */ export const saasClient = new SaaSClient('https://saas.zclaw.com');