/** * Cloud Client Service * * Singleton service responsible for all interactions with CrewlyAI Cloud. * Handles authentication, premium template fetching, and subscription * status synchronization. * * Premium templates are loaded into memory only — never written to disk * to prevent IP leakage. * * @module services/cloud/cloud-client.service */ import { type PersistedCloudConfig } from './cloud-config-file.js'; import { type CloudTier, type CloudConnectionStatus } from '../../constants.js'; export type { PersistedCloudConfig }; /** * Decode the `exp` (expiry, seconds since epoch) claim from a JWT without * verifying its signature. Returns null when the token is malformed or has * no `exp`. Used to schedule proactive relay-token refresh before expiry so * a continuously-open relay socket never carries an expired token. * * @param token - JWT string (may be null) * @returns The `exp` claim in seconds, or null when unavailable */ export declare function decodeJwtExp(token: string | null): number | null; /** Summary of a premium template returned by the cloud listing endpoint. */ export interface CloudTemplateSummary { /** Unique template identifier */ id: string; /** Human-readable template name */ name: string; /** Short description */ description: string; /** Minimum subscription tier required */ requiredTier: CloudTier; /** Category tag (e.g. "dev-team", "marketing") */ category: string; } /** Full detail for a single premium template (in-memory only). */ export interface CloudTemplateDetail { /** Unique template identifier */ id: string; /** Human-readable template name */ name: string; /** Full description */ description: string; /** Minimum subscription tier required */ requiredTier: CloudTier; /** Category tag */ category: string; /** Team member role definitions */ roles: Array<{ role: string; prompt: string; }>; /** Task orchestration config */ orchestration: Record; } /** A relay device returned by the Cloud devices API. */ export interface CloudRelayDevice { /** Device/session ID */ sessionId: string; /** Device role */ role: 'orchestrator' | 'agent'; /** Device state */ state: 'waiting' | 'paired' | 'disconnected'; /** Paired peer device ID */ pairedWith: string | null; /** ISO registration timestamp */ registeredAt: string; /** ISO last heartbeat timestamp */ lastHeartbeatAt: string; /** Human-readable device name (legacy field) */ name?: string; /** Human-readable device name returned by Cloud API */ deviceName?: string; /** Unique device identifier */ deviceId?: string; } /** Current cloud connection state exposed by getStatus(). */ export interface CloudStatus { /** Whether the client is currently connected */ connectionStatus: CloudConnectionStatus; /** Cloud API base URL (set after connect) */ cloudUrl: string | null; /** Current subscription tier */ tier: CloudTier; /** ISO timestamp of last successful sync */ lastSyncAt: string | null; } /** * CloudClientService singleton. * * Manages the lifecycle of the connection between a local Crewly instance * and CrewlyAI Cloud. All cloud API calls are made via native fetch with * bearer-token authentication. */ export declare class CloudClientService { private static instance; private readonly logger; /** Cloud API base URL (e.g. "https://api.crewlyai.com") */ private cloudUrl; /** Bearer token obtained during connect() */ private token; /** Refresh token for auto-renewing expired access tokens */ private refreshToken; /** Current connection status */ private connectionStatus; /** Subscription tier reported by cloud */ private tier; /** Timestamp of the most recent successful cloud API call */ private lastSyncAt; /** Timer for proactive token refresh (fires 5 min before expiry) */ private refreshTimer; /** Guard to prevent concurrent refresh attempts */ private refreshInProgress; /** * Refresh tokens Cloud has retired by rotating them for a successor. Kept in * memory only. A caller that still holds one (a stale copy read before the * rotation) must never put it back: Cloud rejects it, so every later refresh * would fail and the user would have to log in again. */ private retiredRefreshTokens; /** Callbacks invoked when the access token is refreshed */ private tokenRefreshCallbacks; /** * Relay-signed access JWT used by BrowserProxyService to register with the * Cloud Relay as role 'agent'. First-class, stored credential — distinct * from the access `token` and decoupled from socket lifetime. NEVER the * Google/Supabase access token (RELAY-TOKEN-TYPE invariant). */ private relayToken; /** Parsed `exp` (seconds) of {@link relayToken}, for proactive refresh. */ private relayTokenExp; /** Timer for proactive relay-token refresh (fires SKEW before exp). */ private relayRefreshTimer; /** Callbacks invoked when the relay token is refreshed (distinct channel). */ private relayTokenRefreshCallbacks; private constructor(); /** * Get the singleton instance. * * @returns CloudClientService instance */ static getInstance(): CloudClientService; /** * Reset the singleton (for testing). */ static resetInstance(): void; /** * Connect to CrewlyAI Cloud by verifying the provided token. * * Calls the cloud auth endpoint to validate credentials and retrieve * the subscription tier. On success the service transitions to * "connected" status. * * @param cloudUrl - Base URL of the CrewlyAI Cloud API * @param token - Authentication token (API key or JWT) * @returns Object with connection result * @throws Error when the auth request fails or token is invalid * * @example * ```ts * const client = CloudClientService.getInstance(); * await client.connect('https://api.crewlyai.com', 'sk-abc123'); * ``` */ connect(cloudUrl: string, token: string, refreshToken?: string): Promise<{ success: boolean; tier: CloudTier; }>; /** * Remember a refresh token that Cloud has replaced with a successor. * * @param retired - The refresh token that was just rotated away */ private retireRefreshToken; /** * Decide which refresh token a connect call may store: the one it was given, * unless that one was already rotated away in this process. A rotation can * therefore never be undone by a stale copy (startup restore, the connect * endpoint, the Google auth endpoints). * * @param incoming - Refresh token the caller passed in (may be undefined) * @returns The token to store, or undefined to keep the current one */ private acceptIncomingRefreshToken; /** * Connect using a locally verified JWT (no remote API call needed). * * Used when the JWT was issued by this same instance or the JWT secret * is shared between OSS and Cloud. Avoids the round-trip to the cloud * auth endpoint. * * @param cloudUrl - Base URL of the CrewlyAI Cloud API * @param token - JWT access token (already verified locally) * @param tier - Subscription tier extracted from the JWT payload * @param refreshToken - Optional refresh token for auto-renewal */ connectLocal(cloudUrl: string, token: string, tier: CloudTier, refreshToken?: string): void; /** * Disconnect from CrewlyAI Cloud. * * Clears stored credentials and resets the connection state. */ disconnect(): void; /** * Path to the persisted cloud config file. Resolved through CREWLY_HOME so * tests (which isolate CREWLY_HOME) can never overwrite the real login. */ static getConfigPath(): string; /** * Load persisted cloud config from disk. * Returns null if file doesn't exist or is invalid. */ loadPersistedConfig(): Promise; /** * Persist current cloud connection config to disk for auto-reconnect. */ private persistConfig; /** * Remove persisted cloud config from disk (on disconnect). */ private removePersistedConfig; /** * Fetch the list of premium templates available on CrewlyAI Cloud. * * Requires an active cloud connection. * * @returns Array of template summaries * @throws Error when not connected or fetch fails * * @example * ```ts * const templates = await client.getTemplates(); * console.log(templates.map(t => t.name)); * ``` */ getTemplates(): Promise; /** * Fetch full detail for a single premium template. * * The returned data is held in memory only and must never be * persisted to disk. * * @param id - Template identifier * @returns Template detail object * @throws Error when not connected, template not found, or fetch fails * * @example * ```ts * const detail = await client.getTemplateDetail('tpl-tiktok-ops'); * ``` */ getTemplateDetail(id: string): Promise; /** * Get the stored cloud API base URL (set during connect). * * @returns Cloud URL or null if not connected */ getCloudUrl(): string | null; /** * Get the current cloud connection status and subscription tier. * * @returns Current status snapshot */ getStatus(): CloudStatus; /** * Check whether the client is currently connected to cloud. * * @returns true if connected */ isConnected(): boolean; /** * Check whether the cloud token has expired (401/403 from cloud API). * * @returns true if the token has expired */ isTokenExpired(): boolean; /** * Get the current subscription tier. * * @returns Current tier value */ getTier(): CloudTier; /** * Fetch the list of devices registered to this user from Cloud. * * Proxies GET /api/v1/relay/devices on crewlyai.com and returns * the device list for the authenticated user. * * @returns Array of cloud relay devices * @throws Error when not connected or fetch fails */ fetchCloudDevices(): Promise; /** * Set the refresh token for auto-renewal. * * Called by the connect controller after OAuth login provides a refresh token. * * @param refreshToken - Refresh token string */ setRefreshToken(refreshToken: string): void; /** * Get the current access token (for CloudSyncService token updates). * * @returns Current access token or null */ getToken(): string | null; /** * Get the persisted refresh token (long-lived), or null when absent. * Used by mobile-pair so the phone can auto-renew its adopted session. */ getRefreshToken(): string | null; /** * Register a callback to be invoked whenever the access token is refreshed. * Used by BrowserProxyService to update its relay auth token in real-time. * * @param callback - Function receiving the new access token string */ onTokenRefresh(callback: (newToken: string) => void): void; /** * Get the current relay-signed token (for BrowserProxyService registration). * * This is DISTINCT from {@link getToken} (the access/HTTP token). The * BrowserProxy must register with the relay token, never the access token — * see the RELAY-TOKEN-TYPE invariant. Returns null until a relay token has * been minted by the Cloud validate/refresh endpoint. * * @returns Current relay token or null */ getRelayToken(): string | null; /** * Register a callback invoked whenever the relay token is refreshed. * * Distinct subscription channel from {@link onTokenRefresh}: subscribers * here (BrowserProxyService) receive the relay-signed token, never the * access token. Decoupling the channels prevents the proxy from ever being * fed the wrong token type on refresh. * * @param callback - Function receiving the new relay token string */ onRelayTokenRefresh(callback: (newRelayToken: string) => void): void; /** * Store a freshly-minted relay token as a first-class credential, parse its * expiry, schedule proactive refresh, persist it, and notify subscribers. * * Centralizes relay-token handling so every code path that obtains one * (connect, refresh, validate) treats it identically. Persistence is * fire-and-forget; a persist failure must not break the refresh chain. * * @param relayToken - The relay-signed access JWT from Cloud */ private storeRelayToken; /** * Attempt to refresh the access token using the stored refresh token. * * Issues a new access token locally (since both access and refresh tokens * are signed with the same HMAC secret on this OSS instance). * * @returns true if refresh succeeded, false if no refresh token or refresh failed */ tryRefreshToken(): Promise; /** * Schedule a proactive token refresh 5 minutes before expiry. * * Parses the JWT `exp` claim and sets a timer. If the token has * less than 5 minutes remaining, refreshes immediately. * * @param token - JWT access token to extract expiry from */ private scheduleTokenRefresh; /** * Schedule a proactive relay-token refresh `RELAY_REFRESH_SKEW_S` seconds * before the relay token's real `exp`. * * The relay token (~60min TTL) is decoupled from the access token, so it * needs its own refresh schedule. When the timer fires it calls * {@link fetchRelayTokenFromValidate}, which re-mints a relay token and * stores it (re-arming this timer). This keeps a continuously-open relay * socket from ever carrying an expired token — the DURABLE-CREDENTIAL * invariant. Driven by the JWT `exp` claim, not a wall-clock heuristic. */ private scheduleRelayTokenRefresh; /** * Fetch relay token by calling Cloud validate endpoint. * Used after connectLocal() to get a Cloud-signed relay JWT * without blocking the connection flow. * * @returns Promise that resolves when relay token is fetched (or skipped) */ private fetchRelayTokenFromValidate; /** * Throw if the client is not in a connected state. * * @throws Error when not connected */ private ensureConnected; /** * Build the standard authorization headers for cloud API requests. * * @returns Headers object with Authorization and Content-Type */ private authHeaders; /** * Check if an HTTP status code indicates an authentication/authorization failure. * * @param status - HTTP status code * @returns true if the status is 401 or 403 */ private isAuthError; /** * Handle a 401/403 response from the cloud API by transitioning * the connection status to TOKEN_EXPIRED. This signals the frontend * to show a reconnect prompt instead of a raw error. * * @param context - Description of the failed operation (for logging) * @param status - HTTP status code from the cloud API */ private handleAuthFailure; } /** * Whether a JWT refresh token expires within `withinMs` (unreadable or * missing `exp`: no). * * @param token - The refresh token * @param withinMs - Window * @param now - Clock (epoch ms) * @returns True when it expires within the window (or already has) */ export declare function refreshTokenExpiresWithin(token: string | null, withinMs: number, now?: number): boolean; //# sourceMappingURL=cloud-client.service.d.ts.map