Files
zclaw_openfang/desktop/src/lib/saas-client.ts
iven f846f3d632
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(tauri): update @reserved annotations + remove dead SaaS client methods
- Update 9 @reserved → @connected for commands with frontend consumers:
  zclaw_status, zclaw_start, zclaw_stop, zclaw_restart, zclaw_doctor,
  viking_add_with_metadata, viking_store_with_summaries,
  trigger_execute, scheduled_task_create
- Remove 10 dead SaaS client methods with zero callers:
  healthCheck, listDevices (saas-client.ts)
  getRelayTask, getUsage/relay (saas-relay.ts)
  listPrompts, getPrompt, listPromptVersions, getPromptVersion (saas-prompt.ts)
  getPlan, getUsage/billing (saas-billing.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 00:22:45 +08:00

512 lines
16 KiB
TypeScript

/**
* 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,
SyncConfigRequest,
ConfigDiffResponse,
ConfigSyncResult,
SaaSErrorResponse,
RelayTaskInfo,
PromptCheckResult,
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';
export type { UsageIncrementResult } from './saas-billing';
// Re-export billing types for convenience
export type {
BillingPlan,
Subscription,
UsageQuota,
SubscriptionInfo,
CreatePaymentRequest,
PaymentResult,
PaymentStatus,
} from './saas-types';
// === Client Implementation ===
export class SaaSClient {
private baseUrl: string;
private token: string | null = null;
private refreshTokenValue: 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<string> | 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<string> {
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;
}
/** Set or clear the refresh token (in-memory only, never persisted) */
setRefreshToken(token: string | null): void {
this.refreshTokenValue = token;
}
/** Get the current refresh token */
getRefreshToken(): string | null {
return this.refreshTokenValue;
}
/** 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<SaaSAccountInfo | null> {
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<T>(
method: string,
path: string,
body?: unknown,
timeoutMs = 15000,
_isRefreshRetry = false,
): Promise<T> {
const maxRetries = 2;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const headers: Record<string, string> = {
'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<T>(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<T>;
} 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', '请求失败');
}
// --- 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<void> {
await this.request<unknown>('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<void> {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
device_id: deviceId,
platform: typeof navigator !== 'undefined' ? navigator.platform : undefined,
app_version: appVersion,
});
}
// --- Model Endpoints ---
/**
* List available models for relay.
* Only returns enabled models from enabled providers.
*/
async listModels(): Promise<SaaSModelInfo[]> {
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
}
// --- Config Endpoints ---
/**
* List config items, optionally filtered by category.
*/
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
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<ConfigDiffResponse> {
return this.request<ConfigDiffResponse>('POST', '/api/v1/config/diff', request);
}
/** Sync config from client to SaaS (push) or merge */
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
return this.request<ConfigSyncResult>('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<AgentTemplateAvailable[]> {
return this.request<AgentTemplateAvailable[]>('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<AgentTemplateFull> {
return this.request<AgentTemplateFull>('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<AgentTemplateFull> {
return this.request<AgentTemplateFull>('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<AgentTemplateFull | null> {
return this.request<AgentTemplateFull | null>('GET', '/api/v1/accounts/me/assigned-template');
}
/**
* Unassign the current template from the account.
*/
async unassignTemplate(): Promise<void> {
await this.request<unknown>('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<AgentConfigFromTemplate> {
return this.request<AgentConfigFromTemplate>('POST', `/api/v1/agent-templates/${templateId}/create-agent`);
}
}
// === Install mixin methods ===
installAuthMethods(SaaSClient);
installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
installBillingMethods(SaaSClient);
export { installBillingMethods };
// === 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<SaaSLoginResponse>;
register(data: { username: string; email: string; password: string; display_name?: string }): Promise<SaaSLoginResponse>;
me(): Promise<SaaSAccountInfo>;
refreshToken(): Promise<string>;
changePassword(oldPassword: string, newPassword: string): Promise<void>;
setupTotp(): Promise<TotpSetupResponse>;
verifyTotp(code: string): Promise<TotpResultResponse>;
disableTotp(password: string): Promise<TotpResultResponse>;
// --- Relay (saas-relay.ts) ---
listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]>;
retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }>;
chatCompletion(body: unknown, signal?: AbortSignal): Promise<Response>;
// --- Prompt OTA (saas-prompt.ts) ---
checkPromptUpdates(deviceId: string, currentVersions: Record<string, number>): Promise<PromptCheckResult>;
// --- 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<import('./saas-billing').UsageIncrementResult>;
reportUsageFireAndForget(dimension: string, count?: number): void;
listPlans(): Promise<import('./saas-types').BillingPlan[]>;
getSubscription(): Promise<import('./saas-types').SubscriptionInfo>;
createPayment(data: import('./saas-types').CreatePaymentRequest): Promise<import('./saas-types').PaymentResult>;
getPaymentStatus(paymentId: string): Promise<import('./saas-types').PaymentStatus>;
}
// === 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');