/** * Web-app session exchange (claim-code mint + refresh-grant renewal). * * A web app is a fully public phone client behind a dynamically rendered QR * code. It establishes a phyhub session in two steps: * 1. First mint — redeem the one-time claim code carried in the QR URL * fragment: `POST {sessionBaseUrl}/api/v1/web-sessions { urlId, code }`. * The mint is served by phyhub itself; `sessionBaseUrl` is the regional * phyhub base — via the gateway passthrough that is * `{gatewayUrl}/regions/{region}/phyhub` (region comes from `boot.json`). * 2. Renewal — redeem the rotating refresh grant returned by every * successful exchange: same endpoint, body `{ refreshToken }` * * Both return `{ token, expiresIn, expiresAt, refreshToken }` on 200. The * token deadline is computed CLIENT-SIDE from `expiresIn` at response receipt * — phones have skewed clocks, so `expiresAt` (server clock) must never drive * scheduling; it is kept for logging only. * * This is a pure helper — no socket lifecycle here. The connection service * calls it at connect time and on every refresh layer. */ /** * 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 WebAppSessionFetch = ( input: string, init?: { method?: string; headers?: Record; body?: string }, ) => Promise; export interface WebAppSessionExchangeParams { /** Public path segment identifying the WebEndpoint. */ urlId: string; /** Base URL for the session endpoint host (Core, or the API gateway proxying it). No trailing slash required. */ sessionBaseUrl: string; /** One-time claim code read from the QR URL fragment — first mint. */ code?: string; /** Rotating refresh grant from a previous exchange — renewal. Exactly one of `code` / `refreshToken`. */ refreshToken?: string; /** Override fetch (used in tests). Defaults to `globalThis.fetch`. */ fetch?: WebAppSessionFetch; } export interface WebAppSession { token: string; /** Rotated on every exchange — always store the latest one. */ refreshToken: string; /** Token lifetime in seconds, as reported by the server. */ expiresIn: number; /** Server-clock ISO 8601 expiry — logging only; scheduling must use `deadlineMs`. */ expiresAt: string; /** Client-clock expiry: `Date.now() + expiresIn * 1000` captured at response receipt. */ deadlineMs: number; /** * Client-clock end of the WHOLE session (the refresh chain's absolute * deadline): `Date.now() + deadlineInSeconds * 1000` captured at response * receipt — storage eviction keys on it. */ sessionDeadlineMs: number; } /** * `terminal: true` means the session cannot be recovered by retrying — the * claim code or refresh grant is invalid/expired/revoked (401) or the * endpoint is unknown (404). The only way forward is a new QR scan. */ export class WebAppSessionError extends Error { constructor( message: string, public readonly status?: number, public readonly terminal: boolean = false, ) { super(message); this.name = 'WebAppSessionError'; } } /** * Exchange a claim code (first mint) or refresh grant (renewal) for a * short-lived web-app session token. * * Failure modes: * - 401 → invalid/expired code or refresh grant — terminal, rescan the QR * - 404 → unknown urlId — terminal * - 429 → rate-limited — retryable with backoff * - 5xx / network error → retryable * - 200 with malformed body → throws (no silent fallback) */ export const exchangeWebAppSession = async (params: WebAppSessionExchangeParams): Promise => { const { urlId, sessionBaseUrl, code, refreshToken } = params; if (!urlId) throw new WebAppSessionError('urlId required', undefined, true); if (!sessionBaseUrl) throw new WebAppSessionError('sessionBaseUrl required', undefined, true); if (!code && !refreshToken) { throw new WebAppSessionError('either code or refreshToken required', undefined, true); } if (code && refreshToken) { throw new WebAppSessionError('code and refreshToken are mutually exclusive', undefined, true); } const fetchImpl: WebAppSessionFetch = params.fetch ?? ((input, init) => globalThis.fetch(input, init as RequestInit)); // Flat body-keyed route (TECH-1469): the urlId is the composite // `{tenantSlug}/{name}` public path, whose slash does not fit a path param. const url = `${sessionBaseUrl.replace(/\/$/, '')}/api/v1/web-sessions`; let response: Response; try { response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(code ? { urlId, code } : { urlId, refreshToken }), }); } catch (error) { throw new WebAppSessionError(`web-app session exchange failed: ${(error as Error).message ?? error}`); } if (response.status === 401) { throw new WebAppSessionError( 'web-app session exchange rejected: invalid or expired code or refresh grant', 401, true, ); } if (response.status === 404) { throw new WebAppSessionError('web-app session exchange rejected: unknown web endpoint urlId', 404, true); } if (!response.ok) { const text = await response.text().catch(() => ''); throw new WebAppSessionError( `web-app session exchange failed: HTTP ${response.status} ${text}`.trim(), response.status, ); } let body: unknown; try { body = await response.json(); } catch (error) { throw new WebAppSessionError( `web-app session exchange returned non-JSON body: ${(error as Error).message ?? error}`, ); } if ( !body || typeof body !== 'object' || typeof (body as { token?: unknown }).token !== 'string' || !(body as { token: string }).token || typeof (body as { refreshToken?: unknown }).refreshToken !== 'string' || !(body as { refreshToken: string }).refreshToken || typeof (body as { expiresIn?: unknown }).expiresIn !== 'number' || typeof (body as { expiresAt?: unknown }).expiresAt !== 'string' || typeof (body as { deadlineInSeconds?: unknown }).deadlineInSeconds !== 'number' ) { throw new WebAppSessionError('web-app session exchange returned unexpected payload shape'); } const parsed = body as { token: string; refreshToken: string; expiresIn: number; expiresAt: string; deadlineInSeconds: number; }; if (!Number.isFinite(parsed.expiresIn) || parsed.expiresIn <= 0) { throw new WebAppSessionError(`web-app session exchange returned invalid expiresIn: ${parsed.expiresIn}`); } if (!Number.isFinite(parsed.deadlineInSeconds) || parsed.deadlineInSeconds < 0) { throw new WebAppSessionError( `web-app session exchange returned invalid deadlineInSeconds: ${parsed.deadlineInSeconds}`, ); } return { token: parsed.token, refreshToken: parsed.refreshToken, expiresIn: parsed.expiresIn, expiresAt: parsed.expiresAt, deadlineMs: Date.now() + parsed.expiresIn * 1000, sessionDeadlineMs: Date.now() + parsed.deadlineInSeconds * 1000, }; }; /** * Read the one-time claim code from the URL fragment (`#code=...`) — the QR * rendered by the issuing surface encodes `{WEB_ROOT_URL}/{urlId}/#code={code}`. * Returns `undefined` outside a browser or when the fragment carries no code. */ export const readWebSessionCodeFromLocation = (): string | undefined => { if (typeof window === 'undefined') return undefined; return new URLSearchParams(window.location.hash.slice(1)).get('code') || undefined; }; /** * The public-safe boot descriptor the deploys service publishes next to every * web app bundle (`{urlId}/boot.json`) — routing info only, no settings, no * secrets. Its presence is what identifies a page as a deployed web app. */ export interface WebAppBoot { /** The composite public path `{tenantSlug}/{name}` — the mint body's key. */ urlId: string; /** Socket.IO base the web session connects to. */ phyhubUrl: string; /** Public REST base the mint appends `/api/v1/web-sessions` to. */ sessionBaseUrl: string; } /** * Fetch `./boot.json` relative to the current page. Returns `null` when the * file is absent or malformed (i.e. this page is not a deployed web app) — * callers use that as the web-app detection signal. No-op outside a browser. */ export const loadWebAppBoot = async (fetchImpl?: WebAppSessionFetch): Promise => { if (typeof window === 'undefined') return null; const doFetch: WebAppSessionFetch = fetchImpl ?? ((input, init) => globalThis.fetch(input, init as RequestInit)); try { const response = await doFetch('./boot.json', { method: 'GET' }); if (!response.ok) return null; const boot = (await response.json()) as Partial; if (!boot.urlId || !boot.phyhubUrl || !boot.sessionBaseUrl) return null; return { urlId: boot.urlId, phyhubUrl: boot.phyhubUrl, sessionBaseUrl: boot.sessionBaseUrl }; } catch (error) { console.warn('[web-app] Failed to fetch ./boot.json', error); return null; } }; /** * Remove the claim code from the URL fragment (history.replaceState, so no * navigation). Called after a successful code redemption: the code must not * linger in the address bar / history / share-sheet — single-use codes are * spent, but multi-use codes stay redeemable until their TTL, so a shared * URL would leak a working code. Other fragment params are preserved. * No-op outside a browser. */ export const clearWebSessionCodeFromLocation = (): void => { if (typeof window === 'undefined' || typeof window.history?.replaceState !== 'function') { return; } const fragmentParams = new URLSearchParams(window.location.hash.slice(1)); if (!fragmentParams.has('code')) return; fragmentParams.delete('code'); const remaining = fragmentParams.toString(); const url = new URL(window.location.href); url.hash = remaining ? `#${remaining}` : ''; window.history.replaceState(window.history.state, '', url.toString()); }; // --------------------------------------------------------------------------- // Persisted web session (TECH-1468) // // The rotating refresh grant is persisted in localStorage, keyed per endpoint // urlId, so a session survives page reloads, tab discards, and browser kills // up to the server's absolute session deadline. Server-side reuse detection // (a replayed grant revokes the session) is what makes browser persistence // acceptable — a stolen grant is one-time-use, scoped to one endpoint, and // self-revealing. See docs/web-app-session-resilience-design.md. // // Every helper is try/catch-wrapped: storage failures (private mode, // storage-disabled WebViews) degrade to today's memory-only behavior, never // to a crash. Token values are never logged — only presence, savedAt, and // deadline countdowns. export const WEB_SESSION_STORAGE_KEY_PREFIX = 'phystack:web-session:'; export const STORED_WEB_SESSION_VERSION = 1; /** * The persisted record. `urlId` is re-embedded and checked on read: hub-client * only ever resumes a record written for the endpoint in its own boot.json. * That is collision-safety on the shared web origin, NOT a security boundary * against malicious co-tenant JS — only per-endpoint origin isolation gives * that (resilience design §5a, tracked separately). */ export interface StoredWebSession { v: number; /** The current (unconsumed) rotating refresh grant. */ refreshToken: string; /** Client-clock end of the whole session — eviction key. */ sessionDeadlineMs: number; /** The composite `{tenantSlug}/{name}` this record belongs to. */ urlId: string; /** Client-clock write time (diagnostics only). */ savedAt: number; } /** Minimal storage surface — injectable so tests run without a browser. */ export interface WebSessionStorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } /** * localStorage, or null when unavailable. The ACCESSOR itself can throw in * some WebViews and privacy modes, hence the try/catch around the property * read — not only around the item operations. */ const getDefaultWebSessionStorage = (): WebSessionStorageLike | null => { try { const globalWithStorage = globalThis as { localStorage?: WebSessionStorageLike }; return globalWithStorage.localStorage ?? null; } catch (error) { console.warn('[web-app] Failed to access localStorage for web session persistence', error); return null; } }; const buildWebSessionStorageKey = (urlId: string): string => `${WEB_SESSION_STORAGE_KEY_PREFIX}${urlId}`; const parseStoredWebSession = (raw: string): StoredWebSession | null => { try { const parsed = JSON.parse(raw) as Partial; if ( !parsed || parsed.v !== STORED_WEB_SESSION_VERSION || typeof parsed.refreshToken !== 'string' || !parsed.refreshToken || typeof parsed.sessionDeadlineMs !== 'number' || typeof parsed.urlId !== 'string' || typeof parsed.savedAt !== 'number' ) { return null; } return { v: parsed.v, refreshToken: parsed.refreshToken, sessionDeadlineMs: parsed.sessionDeadlineMs, urlId: parsed.urlId, savedAt: parsed.savedAt, }; } catch (error) { console.warn('[web-app] Failed to parse stored web session record', error); return null; } }; /** * Read the stored session for `urlId`. Eagerly evicts and returns null when * the record is malformed, was written for a different urlId, or its session * deadline has passed — a stale record replays a consumed grant on the next * resume and trips server-side reuse detection, so no record beats a bad one. */ export const readStoredWebSession = ( urlId: string, storage: WebSessionStorageLike | null = getDefaultWebSessionStorage(), ): StoredWebSession | null => { if (!storage) return null; const storageKey = buildWebSessionStorageKey(urlId); try { const raw = storage.getItem(storageKey); if (!raw) return null; const record = parseStoredWebSession(raw); if (!record || record.urlId !== urlId) { console.warn(`[web-app] stored web session for '${urlId}' is invalid or mismatched — clearing it`); storage.removeItem(storageKey); return null; } if (record.sessionDeadlineMs <= Date.now()) { console.info(`[web-app] stored web session for '${urlId}' passed its deadline — clearing it`); storage.removeItem(storageKey); return null; } console.info( `[web-app] found stored web session for '${urlId}' (savedAt ${new Date(record.savedAt).toISOString()}, ` + `${Math.round((record.sessionDeadlineMs - Date.now()) / 1000)}s until deadline)`, ); return record; } catch (error) { console.warn('[web-app] Failed to read stored web session', error); return null; } }; /** Persist a session record. Returns false on failure — the caller must treat that as "no record" and clear. */ export const writeStoredWebSession = ( record: StoredWebSession, storage: WebSessionStorageLike | null = getDefaultWebSessionStorage(), ): boolean => { if (!storage) return false; try { storage.setItem(buildWebSessionStorageKey(record.urlId), JSON.stringify(record)); return true; } catch (error) { console.warn('[web-app] Failed to write stored web session', error); return false; } }; export const clearStoredWebSession = ( urlId: string, storage: WebSessionStorageLike | null = getDefaultWebSessionStorage(), ): void => { if (!storage) return; try { storage.removeItem(buildWebSessionStorageKey(urlId)); console.info(`[web-app] cleared stored web session for '${urlId}'`); } catch (error) { console.warn('[web-app] Failed to clear stored web session', error); } };