/** * Auth -- Credential lifecycle manager * * Single source of truth for TIM credentials. * Handles caching, expiry detection, refresh, and cooldown. * * Usage: AccountRuntime holds one instance per agent. * Call getCredentials() whenever you need valid TIM credentials -- * it returns cached ones if still valid, or fetches fresh ones from backend. * * See docs/repair/002-auth-lifecycle-v3.0.5.md for full design. */ import { verifyAgent, type TIMCredentials } from './verify.js'; import { logger } from '../util/logger.js'; // ── Constants ── /** Cooldown after a failed attempt (unclaimed / network error) */ const COOLDOWN_MS = 30_000; /** Refresh credentials 5 minutes before actual expiry */ const EXPIRE_MARGIN_MS = 5 * 60 * 1000; // ── CredentialError ── export type CredentialErrorReason = 'unclaimed' | 'cooldown' | 'error'; /** * Thrown by getCredentials() when valid credentials cannot be obtained. * Callers use `reason` to decide how to respond to the user. */ export class CredentialError extends Error { constructor( public readonly reason: CredentialErrorReason, message: string, ) { super(message); this.name = 'CredentialError'; } } // ── CredentialManager ── export type CredentialStatus = 'idle' | 'unclaimed' | 'valid' | 'expired' | 'error'; export class CredentialManager { private _credentials: TIMCredentials | null = null; private _expiresAt = 0; private _cooldownUntil = 0; private _status: CredentialStatus = 'idle'; constructor( private readonly _agentId: string, private readonly _apiKey: string, ) {} // ── Public API ── /** * Get valid TIM credentials. * * - Has cached + not expired -> return immediately (zero network cost) * - In cooldown -> throw CredentialError('cooldown') * - No cache or expired -> call verifyAgent() to fetch fresh credentials * - claimed=true -> cache + return * - claimed=false -> throw CredentialError('unclaimed') * - network error -> throw CredentialError('error') */ async getCredentials(): Promise { // Fast path: valid cache if (this._credentials && Date.now() < this._expiresAt) { return this._credentials; } // Cooldown protection const now = Date.now(); if (now < this._cooldownUntil) { const remainSec = Math.ceil((this._cooldownUntil - now) / 1000); throw new CredentialError('cooldown', `Credential refresh on cooldown (${remainSec}s remaining)`); } // Fetch from backend try { const result = await verifyAgent(this._agentId, this._apiKey); if (!result) { // unclaimed: verifyAgent returns null when agent is not claimed this._status = 'unclaimed'; this._cooldownUntil = Date.now() + COOLDOWN_MS; throw new CredentialError('unclaimed', `Agent ${this._agentId} is not claimed yet (bind email first)`); } // Success this._credentials = result; this._expiresAt = Date.now() + (result.expire * 1000) - EXPIRE_MARGIN_MS; this._status = 'valid'; logger.info( `[auth/credential] Credentials obtained for ${this._agentId}, ` + `valid until ${new Date(this._expiresAt).toISOString()}`, ); return this._credentials; } catch (err) { if (err instanceof CredentialError) throw err; // Network / server error this._status = 'error'; this._cooldownUntil = Date.now() + COOLDOWN_MS; throw new CredentialError('error', `Credential fetch failed: ${(err as Error).message}`); } } /** Current credential status (for logging/debugging) */ get status(): CredentialStatus { return this._status; } /** Whether cached credentials are still valid */ get isValid(): boolean { return this._status === 'valid' && this._credentials !== null && Date.now() < this._expiresAt; } /** * Force-expire cached credentials. * * Call this when TIM SDK reports KICKED_OUT with type='userSigExpired'. * Do NOT call for network disconnects -- old credentials are still valid. * Next getCredentials() call will fetch fresh ones from backend. */ invalidate(): void { this._credentials = null; this._expiresAt = 0; this._status = 'expired'; logger.info(`[auth/credential] Credentials invalidated for ${this._agentId}`); } }