/** * Cloud-app token exchange. * * The cloud-app process exchanges its long-lived `{ appRegistrationId, appSecret }` (issued * at Gridapp create time, Phase 10) for a short-lived JWT used as the * Socket.IO handshake credential. The endpoint lives in Core * (Phase 13: `POST /api/2026-03/cloud-apps/:appRegistrationId/token`) and returns * `{ token, expiresAt }` on 200. * * This is a pure helper — no socket lifecycle here. The connection service * calls it once at connect time and again on the refresh timer. */ /** * Minimal fetch shape we depend on — narrower than `typeof fetch` so tests can * supply a stub without having to satisfy unused fields like `preconnect`. */ export type CloudAppFetch = ( input: string, init?: { method?: string; headers?: Record; body?: string }, ) => Promise; export interface CloudAppTokenExchangeParams { appRegistrationId: string; appSecret: string; /** Base URL for Core (or the API gateway proxying it). No trailing slash required. */ coreApiUrl: string; /** Override fetch (used in tests). Defaults to `globalThis.fetch`. */ fetch?: CloudAppFetch; } export interface CloudAppToken { token: string; /** ISO 8601 string emitted by Core. */ expiresAt: string; /** Parsed `expiresAt` for scheduling — caller does not have to re-parse. */ expiresAtMs: number; } export class CloudAppTokenExchangeError extends Error { constructor( message: string, public readonly status?: number, ) { super(message); this.name = 'CloudAppTokenExchangeError'; } } /** * Exchange `{ appRegistrationId, appSecret }` for a short-lived JWT. * * Failure modes: * - 401 → invalid credentials, throws `CloudAppTokenExchangeError` (callers * should NOT retry — the secret is wrong, not transient) * - 5xx / network error → throws (callers may retry with backoff) * - 200 with malformed body → throws (no silent fallback) */ export const exchangeCloudAppToken = async (params: CloudAppTokenExchangeParams): Promise => { const { appRegistrationId, appSecret, coreApiUrl } = params; if (!appRegistrationId) throw new CloudAppTokenExchangeError('appRegistrationId required'); if (!appSecret) throw new CloudAppTokenExchangeError('appSecret required'); if (!coreApiUrl) throw new CloudAppTokenExchangeError('coreApiUrl required'); const fetchImpl: CloudAppFetch = params.fetch ?? ((input, init) => globalThis.fetch(input, init as RequestInit)); const url = `${coreApiUrl.replace(/\/$/, '')}/api/2026-03/cloud-apps/${encodeURIComponent(appRegistrationId)}/token`; let response: Response; try { response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appSecret }), }); } catch (error) { throw new CloudAppTokenExchangeError(`cloud-app token exchange failed: ${(error as Error).message ?? error}`); } if (response.status === 401) { throw new CloudAppTokenExchangeError('cloud-app token exchange rejected: invalid credentials', 401); } if (!response.ok) { const text = await response.text().catch(() => ''); throw new CloudAppTokenExchangeError( `cloud-app token exchange failed: HTTP ${response.status} ${text}`.trim(), response.status, ); } let body: unknown; try { body = await response.json(); } catch (error) { throw new CloudAppTokenExchangeError( `cloud-app token exchange returned non-JSON body: ${(error as Error).message ?? error}`, ); } if ( !body || typeof body !== 'object' || typeof (body as { token?: unknown }).token !== 'string' || typeof (body as { expiresAt?: unknown }).expiresAt !== 'string' ) { throw new CloudAppTokenExchangeError('cloud-app token exchange returned unexpected payload shape'); } const { token, expiresAt } = body as { token: string; expiresAt: string }; const expiresAtMs = Date.parse(expiresAt); if (Number.isNaN(expiresAtMs)) { throw new CloudAppTokenExchangeError(`cloud-app token exchange returned unparseable expiresAt: ${expiresAt}`); } return { token, expiresAt, expiresAtMs }; };