/** * Web-session claim-code issuer (device side of the web-app QR flow). * * A device session (screen/edge app) asks phyhub to mint a short-lived claim * code for a Web endpoint via the `createWebSessionCode` socket event; the ack * carries the complete public URL (`{webAppsRoot}/{urlId}/#code={code}`) that * a phone scans as a QR to start a scoped web session (the consumer half lives * in web-app-session.service.ts / web-app-connection.service.ts). * * Codes expire server-side after the endpoint's `codeTtlSeconds`, so a QR * display must rotate them. The issuer in this file owns that lifecycle so * apps only render state: * - resolves the endpointId once (a settings twin picker hands the app the * Web twin's `_id`, but phyhub's issuer API requires the twin's `deviceId` * — the endpointId; see resolveWebSessionEndpointId) * - renews at a fraction of the code TTL (with downward jitter so a fleet * of screens doesn't mint in sync) * - re-mints after a socket reconnect (the displayed code likely expired * while offline) * - retries failures with capped exponential backoff, reporting an explicit * `unavailable` state so the app hides the QR instead of showing a dead code */ /** Wire name of phyhub's device-session issuer event. */ export const CREATE_WEB_SESSION_CODE_EVENT = 'createWebSessionCode'; /** * Structural slice of a Socket.IO socket — just enough to arm/detach the * reconnect listener. Kept minimal (instead of `Pick`) * so tests can substitute a plain fake without socket.io's generic * reserved-event listener types getting in the way. */ export interface WebSessionCodeSocketLike { on: (event: string, listener: (...args: unknown[]) => void) => unknown; off: (event: string, listener: (...args: unknown[]) => void) => unknown; } /** * Identifies the Web endpoint to mint codes for — exactly one of: * - `twinId`: the Web twin's `_id`, the value a settings twin picker stores * (`{ id, ref: 'twin' }`); resolved to the endpointId via getTwinById. * - `endpointId`: the Web twin's `deviceId`, the id shown by the Console * web-endpoints table and `phy web-endpoint get`. */ export interface WebSessionCodeTarget { twinId?: string; endpointId?: string; } export interface WebSessionCodeOptions extends WebSessionCodeTarget { /** * Fraction of the code TTL after which to mint the next code. Default 0.8 * (a 300s TTL rotates every ~240s). Clamped to (0, 0.95]. */ renewAtFraction?: number; /** How long to wait for the mint ack before treating it as failed. Default 15_000. */ requestTimeoutMs?: number; /** Optional logger. Defaults to the console. */ logger?: Pick; } /** A minted claim code, as delivered by phyhub's ack. */ export interface WebSessionCode { /** Claim code (also embedded in `url`). */ code: string; /** Complete public URL to render as a QR: `{webAppsRoot}/{urlId}/#code={code}`. */ url: string; /** Code lifetime in seconds (the endpoint's `codeTtlSeconds`). */ expiresIn: number; /** Client-clock expiry (`Date.now() + expiresIn * 1000`). */ expiresAt: Date; /** The endpointId the code was minted for. */ endpointId: string; } export type WebSessionCodeState = | { status: 'active'; code: string; url: string; expiresIn: number; expiresAt: Date; endpointId: string; } | { status: 'unavailable'; message: string; /** When the next automatic retry fires; null when the issuer gave up (config error). */ retryAt: Date | null; }; export type WebSessionCodeListener = (state: WebSessionCodeState) => void; export interface WebSessionCodeSubscription { /** Mint a new code now (cancels the pending rotation timer). */ refresh: () => void; /** Stop rotating and release timers/listeners. Idempotent. */ stop: () => void; } /** * Thrown for configuration the issuer cannot retry its way out of — currently * only "the picked twin is not a Web twin". Terminal: the subscription reports * `unavailable` with `retryAt: null` and stops. */ export class WebSessionCodeConfigError extends Error { constructor(message: string) { super(message); this.name = 'WebSessionCodeConfigError'; } } /** * The slice of PhyHubClient the issuer needs, injected so the service is * testable without a socket. `emit` must follow the client's ack convention * (last arg is the ack callback). */ export interface WebSessionCodeClientAdapter { ensureConnection: () => Promise; emit: (method: string, payload: unknown, ack: (response: WebSessionCodeAckPayload) => void) => void; getTwinById: (twinId: string) => Promise<{ deviceId: string; type: string }>; getSocket: () => WebSessionCodeSocketLike | null; } /** * Mirrors phyhub's WebSessionCodeAckPayload (web-session-socket.service.ts). * Every ack this event can produce, verified against the emitting code: * - success: `{ status: 'success', message, code, url, expiresIn }` * (phyhub issueWebSessionCodeAck) * - phyhub failure: `{ status: 'error', message: 'Failed to create web session * code[: ]' }` (phyhub buildErrorAck — covers the opaque * unknown/disabled/foreign-tenant endpoint 404, payload validation, internal * errors) * - device offline from phyhub: `{ status: 'error', message: 'PhyHub instance * not found' }` (device-phyos edge-hub default forward) * - simulator: `{ status: 'error', message: 'Unknown method: * createWebSessionCode' }` (phy-simulator has no issuer support — the QR * stays hidden in simulator dev runs) * - no ack at all when the transport is down — covered by the request timeout. */ export interface WebSessionCodeAckPayload { status?: string; message?: string; code?: string; url?: string; expiresIn?: number; } /** Timer seam so tests control scheduling without patching globals. */ export interface WebSessionCodeTimers { schedule: (callback: () => void, delayMs: number) => unknown; cancel: (handle: unknown) => void; } const DEFAULT_TIMERS: WebSessionCodeTimers = { schedule: (callback, delayMs) => setTimeout(callback, delayMs), cancel: (handle) => clearTimeout(handle as ReturnType), }; const DEFAULT_LOGGER: Pick = { info: (...args: unknown[]) => console.info(...args), warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), }; // Verbose by default while web apps are young (same policy as // web-app-connection.service.ts): every issuer lifecycle step logs to the // console so early field debugging needs no instrumentation. Pass a custom // `logger` (e.g. a no-op) to silence it. Logging the full URL (which embeds // the claim code) is deliberate here — the issuer runs on the device that // renders that same URL as an on-screen QR, so the log adds no exposure. const describeWebSessionCodeTarget = (target: WebSessionCodeTarget): string => target.twinId ? `twinId ${target.twinId}` : `endpointId ${target.endpointId}`; const DEFAULT_RENEW_AT_FRACTION = 0.8; const MAX_RENEW_AT_FRACTION = 0.95; const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; /** Downward-only jitter on the rotation delay (never pushes past the TTL fraction). */ const RENEWAL_JITTER_FRACTION = 0.1; const RETRY_BACKOFF_BASE_MS = 5_000; const RETRY_BACKOFF_CAP_MS = 60_000; /** * Validate a target: exactly one of twinId/endpointId. Throws synchronously — * this is a programmer error, not a runtime condition. */ export const assertWebSessionCodeTarget = (target: WebSessionCodeTarget): void => { const hasTwinId = typeof target.twinId === 'string' && target.twinId.length > 0; const hasEndpointId = typeof target.endpointId === 'string' && target.endpointId.length > 0; if (hasTwinId === hasEndpointId) { throw new Error('Web session code target requires exactly one of twinId or endpointId'); } }; /** * Resolve the target to the endpointId phyhub's issuer requires (the Web * twin's `deviceId`). A twinId that resolves to a non-Web twin is a terminal * configuration error — a twin's type never changes, so retrying cannot fix it. */ export const resolveWebSessionEndpointId = async ( client: Pick, target: WebSessionCodeTarget, ): Promise => { if (target.endpointId) { return target.endpointId; } const twin = await client.getTwinById(target.twinId as string); if (twin.type !== 'Web') { throw new WebSessionCodeConfigError( `Twin ${target.twinId} is a ${twin.type} twin, not a Web twin — check the settings picker`, ); } if (!twin.deviceId) { throw new Error(`Web twin ${target.twinId} has no deviceId (endpointId) — cannot mint codes`); } return twin.deviceId; }; /** * Bound an unowned async step with a timeout so a dropped ack can never wedge * the mint pipeline: `getTwinById` and `ensureConnection` settle only when * their acks arrive (or a socket 'error' fires), so a phyhub restart or a * never-resolving edge-hub forward would otherwise leave the caller awaiting * forever — with `mintInFlight` held, that made the whole subscription * permanently dead. A rejection here lands in the normal backoff/retry path * instead. A late settlement of the underlying promise is discarded silently * (there is nothing left to do with it, and swallowing the late rejection * avoids an unhandled-rejection warning). */ export const guardWithTimeout = ( operation: Promise, timeoutMs: number, timers: WebSessionCodeTimers, operationName: string, ): Promise => { return new Promise((resolve, reject) => { let settled = false; const timeoutHandle = timers.schedule(() => { if (settled) return; settled = true; reject(new Error(`Timed out after ${timeoutMs}ms waiting for ${operationName}`)); }, timeoutMs); operation.then( (result) => { if (settled) return; settled = true; timers.cancel(timeoutHandle); resolve(result); }, (error) => { if (settled) return; settled = true; timers.cancel(timeoutHandle); reject(error); }, ); }); }; /** * Ask phyhub to mint one claim code for the endpoint. Rejects on an error ack, * a malformed ack, or when no ack arrives within `requestTimeoutMs` (the emit * may be queued while the socket is offline — the timeout keeps callers from * hanging on a code that would be stale by the time it arrived). */ export const requestWebSessionCode = ( client: Pick, endpointId: string, requestTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, timers: WebSessionCodeTimers = DEFAULT_TIMERS, ): Promise => { return new Promise((resolve, reject) => { let settled = false; const timeoutHandle = timers.schedule(() => { if (settled) return; settled = true; reject(new Error(`Timed out after ${requestTimeoutMs}ms waiting for the web session code ack`)); }, requestTimeoutMs); client.emit(CREATE_WEB_SESSION_CODE_EVENT, { data: { endpointId } }, (response) => { if (settled) return; settled = true; timers.cancel(timeoutHandle); if ( !response || response.status !== 'success' || typeof response.code !== 'string' || typeof response.url !== 'string' || typeof response.expiresIn !== 'number' ) { const reason = response && response.message ? response.message : 'malformed ack'; reject(new Error(`Failed to create web session code: ${reason}`)); return; } resolve({ code: response.code, url: response.url, expiresIn: response.expiresIn, expiresAt: new Date(Date.now() + response.expiresIn * 1000), endpointId, }); }); }); }; /** * One-shot mint: resolve the endpoint, request a single code. Used by * PhyHubClient.createWebSessionCode(); rotation lives in WebSessionCodeIssuer. */ export const createWebSessionCode = async ( client: WebSessionCodeClientAdapter, options: WebSessionCodeOptions, timers: WebSessionCodeTimers = DEFAULT_TIMERS, ): Promise => { assertWebSessionCodeTarget(options); const logger = options.logger ?? DEFAULT_LOGGER; const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; logger.info(`[web-session-code] Minting one web session code (${describeWebSessionCodeTarget(options)})`); await guardWithTimeout(client.ensureConnection(), timeoutMs, timers, 'the hub connection'); const endpointId = await guardWithTimeout( resolveWebSessionEndpointId(client, options), timeoutMs, timers, 'the web endpoint twin lookup', ); if (options.twinId) { logger.info(`[web-session-code] Resolved twin ${options.twinId} to web endpoint ${endpointId}`); } const issued = await requestWebSessionCode(client, endpointId, options.requestTimeoutMs, timers); logger.info( `[web-session-code] Minted web session code for endpoint ${endpointId}: ${issued.url} (expires in ${issued.expiresIn}s)`, ); return issued; }; /** * Rotating issuer behind PhyHubClient.subscribeWebSessionCode(). The listener * receives the initial code and every rotation through the same callback; all * runtime failures surface as `unavailable` states, never as throws. */ export class WebSessionCodeIssuer implements WebSessionCodeSubscription { private readonly client: WebSessionCodeClientAdapter; private readonly options: WebSessionCodeOptions; private readonly listener: WebSessionCodeListener; private readonly timers: WebSessionCodeTimers; private readonly logger: Pick; private readonly random: () => number; private readonly renewAtFraction: number; private stopped = false; private mintInFlight = false; private endpointId: string | null = null; private consecutiveFailures = 0; private rotationTimerHandle: unknown = null; /** The socket the reconnect handler was armed on, kept for exact detach. */ private reconnectSocket: WebSessionCodeSocketLike | null = null; private reconnectHandler: (() => void) | null = null; constructor( client: WebSessionCodeClientAdapter, options: WebSessionCodeOptions, listener: WebSessionCodeListener, timers: WebSessionCodeTimers = DEFAULT_TIMERS, random: () => number = Math.random, ) { assertWebSessionCodeTarget(options); this.client = client; this.options = options; this.listener = listener; this.timers = timers; this.logger = options.logger ?? DEFAULT_LOGGER; this.random = random; const requestedFraction = options.renewAtFraction ?? DEFAULT_RENEW_AT_FRACTION; this.renewAtFraction = Math.min(Math.max(requestedFraction, 0.05), MAX_RENEW_AT_FRACTION); } /** Kick off the first mint and arm the reconnect re-mint. Never throws. */ public start(): void { this.logger.info( `[web-session-code] Starting code subscription (${describeWebSessionCodeTarget(this.options)}, ` + `renew at ${Math.round(this.renewAtFraction * 100)}% of code TTL)`, ); this.armReconnectListener(); void this.runMint(); } public refresh(): void { if (this.stopped) return; this.logger.info('[web-session-code] Refresh requested — minting a fresh code'); this.cancelRotationTimer(); void this.runMint(); } public stop(): void { if (this.stopped) return; this.stopped = true; this.logger.info('[web-session-code] Code subscription stopped'); this.cancelRotationTimer(); if (this.reconnectSocket && this.reconnectHandler) { this.reconnectSocket.off('connect', this.reconnectHandler); } this.reconnectSocket = null; this.reconnectHandler = null; } private async runMint(): Promise { if (this.stopped || this.mintInFlight) return; this.mintInFlight = true; // Every awaited step below must be time-bounded: an await that never // settles would hold mintInFlight forever, and the single-flight guard // would then silently kill the whole subscription (no retry, no state, // refresh() and reconnects all no-ops). const stepTimeoutMs = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; try { await guardWithTimeout(this.client.ensureConnection(), stepTimeoutMs, this.timers, 'the hub connection'); // The socket may only exist after ensureConnection — arm late if start() // ran before the client connected. this.armReconnectListener(); if (!this.endpointId) { this.endpointId = await guardWithTimeout( resolveWebSessionEndpointId(this.client, this.options), stepTimeoutMs, this.timers, 'the web endpoint twin lookup', ); if (this.options.twinId) { this.logger.info( `[web-session-code] Resolved twin ${this.options.twinId} to web endpoint ${this.endpointId}`, ); } } this.logger.info(`[web-session-code] Requesting a code for endpoint ${this.endpointId}`); const issued = await requestWebSessionCode( this.client, this.endpointId, this.options.requestTimeoutMs, this.timers, ); if (this.stopped) return; this.consecutiveFailures = 0; this.logger.info( `[web-session-code] Code active: ${issued.url} (expires ${issued.expiresAt.toISOString()}, in ${issued.expiresIn}s)`, ); this.emitState({ status: 'active', code: issued.code, url: issued.url, expiresIn: issued.expiresIn, expiresAt: issued.expiresAt, endpointId: issued.endpointId, }); this.scheduleRotation(issued.expiresIn); } catch (error) { if (this.stopped) return; this.handleMintFailure(error); } finally { this.mintInFlight = false; } } private handleMintFailure(error: unknown): void { const message = error instanceof Error ? error.message : String(error); if (error instanceof WebSessionCodeConfigError) { this.logger.error('[web-session-code] Failed to mint web session code (terminal config error)', error); this.emitState({ status: 'unavailable', message, retryAt: null }); return; } this.consecutiveFailures += 1; const backoffMs = Math.min(RETRY_BACKOFF_BASE_MS * Math.pow(2, this.consecutiveFailures - 1), RETRY_BACKOFF_CAP_MS); this.logger.warn( `[web-session-code] Failed to mint web session code (attempt ${this.consecutiveFailures}), retrying in ${backoffMs}ms`, error, ); this.emitState({ status: 'unavailable', message, retryAt: new Date(Date.now() + backoffMs) }); this.rotationTimerHandle = this.timers.schedule(() => { this.rotationTimerHandle = null; void this.runMint(); }, backoffMs); } private scheduleRotation(expiresInSeconds: number): void { this.cancelRotationTimer(); const baseDelayMs = expiresInSeconds * 1000 * this.renewAtFraction; // Downward-only jitter: rotate a bit early, never later than the fraction. const jitteredDelayMs = baseDelayMs * (1 - RENEWAL_JITTER_FRACTION * this.random()); const delayMs = Math.max(jitteredDelayMs, 1000); this.logger.info(`[web-session-code] Next code rotation in ${Math.round(delayMs / 1000)}s`); this.rotationTimerHandle = this.timers.schedule(() => { this.rotationTimerHandle = null; void this.runMint(); }, delayMs); } private cancelRotationTimer(): void { if (this.rotationTimerHandle !== null) { this.timers.cancel(this.rotationTimerHandle); this.rotationTimerHandle = null; } } /** * Re-mint right after a reconnect: the displayed code likely expired while * the socket was down, and a fresh one costs a single event. Armed once per * socket object; if the connection singleton replaces the socket the * rotation timer still keeps codes fresh, so re-arming is best-effort. */ private armReconnectListener(): void { if (this.stopped || this.reconnectSocket) return; const socket = this.client.getSocket(); if (!socket) return; this.reconnectSocket = socket; this.reconnectHandler = () => { this.logger.info('[web-session-code] Socket reconnected — replacing the displayed code'); this.refresh(); }; socket.on('connect', this.reconnectHandler); } private emitState(state: WebSessionCodeState): void { try { this.listener(state); } catch (error) { this.logger.error('[web-session-code] Failed to notify web session code listener', error); } } }