import type { DemoRole } from '../utils/demoEntrance'; // A demo visit lives only in this tab's sessionStorage, never beside an ordinary session in localStorage, // so entering, switching or leaving the demo cannot touch an ordinary sign-in and a new tab inherits none. export const DEMO_SESSION_KEY = 'akashic_demo_session'; // The entry in progress: its request identity and, once leased, the visit's capability. Never a password. export const DEMO_ENTRY_KEY = 'akashic_demo_entry'; // Demo-owned caches kept in the tab by the services that otherwise use localStorage. const DEMO_OWNED_PREFIXES = ['akashic:direct-threads:', 'akashic_unconfirmed_authority_saves']; export interface DemoTabSession { tabId: string; role: DemoRole; leaseId: string; leaseSecret: string; expiresAt: string; agreementVersion: string; /** The generation the server stamped in this visit's tokens, and the clinic activation confirmed. */ generation: string; clinicId: string; } export interface DemoEntryRecord { attempt: string; requestId: string; role: DemoRole; agreementVersion: string; lease?: { leaseId: string; leaseSecret: string; expiresAt: string }; } const isText = (value: unknown): value is string => typeof value === 'string' && value.length > 0; const readJson = (store: Storage, key: string): any => { try { return JSON.parse(store.getItem(key) ?? 'null'); } catch { return null; } }; export const readDemoSession = (store: Storage = window.sessionStorage): DemoTabSession | undefined => { const parsed = readJson(store, DEMO_SESSION_KEY); const fields = ['tabId', 'role', 'leaseId', 'leaseSecret', 'expiresAt', 'agreementVersion', 'generation', 'clinicId']; return parsed && fields.every((field) => isText(parsed[field])) ? (parsed as DemoTabSession) : undefined; }; export const writeDemoSession = (session: DemoTabSession, store: Storage = window.sessionStorage): void => { store.setItem(DEMO_SESSION_KEY, JSON.stringify(session)); }; export const readDemoEntry = (store: Storage = window.sessionStorage): DemoEntryRecord | undefined => { const parsed = readJson(store, DEMO_ENTRY_KEY); return parsed && isText(parsed.attempt) && isText(parsed.requestId) && isText(parsed.role) && isText(parsed.agreementVersion) ? (parsed as DemoEntryRecord) : undefined; }; export const writeDemoEntry = (record: DemoEntryRecord, store: Storage = window.sessionStorage): void => { store.setItem(DEMO_ENTRY_KEY, JSON.stringify(record)); }; /** Finishes the entry only if it is still the one this attempt wrote; a later attempt's record stays. */ export const finishDemoEntry = (attempt: string, store: Storage = window.sessionStorage): void => { if (readDemoEntry(store)?.attempt === attempt) store.removeItem(DEMO_ENTRY_KEY); }; // Everything this tab keeps for the demo: the visit, its entry, its auth keys and its demo-owned caches. A // replacement being committed keeps its own entry record until the commit finishes. export const clearDemoTab = (prefix: string, store: Storage = window.sessionStorage, keepEntry = false): void => { for (let i = store.length - 1; i >= 0; i--) { const key = store.key(i); if (!key || (keepEntry && key === DEMO_ENTRY_KEY)) continue; const demoOwned = key === DEMO_SESSION_KEY || key === DEMO_ENTRY_KEY || key.startsWith(prefix + '_') || DEMO_OWNED_PREFIXES.some((owned) => key.startsWith(owned)); if (demoOwned) store.removeItem(key); } }; export const newTabId = (): string => crypto.randomUUID(); export const demoDpopRecord = (tabId: string): string => 'demo:' + tabId; /** * The server's demo claims, read from the ID token's payload. Display and matching only - the API authorizer * checks the signature and the live lease on every request, and the visit is confirmed with the server before * the app starts. */ export interface DemoClaims { generation: string; role: DemoRole; } export const demoClaimsFrom = (idToken: string | null | undefined): DemoClaims | undefined => { try { const payload = JSON.parse(atob((idToken ?? '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); if (payload.demo !== 'true' || !/^\d{1,3}#[A-Za-z0-9-]{1,64}$/.test(payload.demo_generation ?? '') || !isText(payload.demo_role)) { return undefined; } return { generation: payload.demo_generation, role: payload.demo_role }; } catch { return undefined; } }; type Locks = Pick; const held = new Map; release: () => void }>(); /** * Whether this tab holds its visit. An exclusive browser lock named for the lease is taken once per page and * kept until the page goes or the visit is refused, so two copies of a tab - however close together, before * or after either commits, and whether or not the original is suspended - can never both hold it. Without * the lock API nothing is held. A refusal is not kept, so it never outlives the holder it lost to. */ export const claimDemoTab = (leaseId: string, locks: Locks | undefined = globalThis.navigator?.locks): Promise<'held' | 'copy'> => { const existing = held.get(leaseId); if (existing) return existing.result; let release = () => undefined as void; const released = new Promise((resolve) => { release = resolve; }); const result = new Promise<'held' | 'copy'>((resolve) => { if (!locks) { resolve('copy'); return; } locks.request('akashic-demo-visit:' + leaseId, { ifAvailable: true }, async (lock) => { if (!lock) { resolve('copy'); return; } resolve('held'); await released; }).catch(() => resolve('copy')); }); held.set(leaseId, { result, release }); void result.then((holder) => { if (holder === 'copy' && held.get(leaseId)?.result === result) held.delete(leaseId); }); return result; }; /** Gives up this page's hold on a visit it no longer admits. */ export const releaseDemoTab = (leaseId: string): void => { held.get(leaseId)?.release(); held.delete(leaseId); }; export const DEMO_ENDED_EVENT = 'akashic-demo-ended'; // Only an ended or unresolvable visit ends it. An action the demo does not offer (DEMO_UNAVAILABLE) or a // clinic it does not reach is refused in place, and an ordinary permission denial is just that. const ENDING_REFUSALS = ['DEMO_ENDED', 'DEMO_UNRESOLVED']; /** Whether a refusal ends the visit: the gateway's projected 403 or a handler's 410, named as ending. */ export const isEndingRefusal = (status: number, code: unknown): boolean => (status === 403 || status === 410) && ENDING_REFUSALS.includes(code as string); /** A refusal from the API that ends this tab's visit, announced to the page that holds it. */ export const noteDemoRefusal = async (response: Response): Promise => { if (response.status !== 403 && response.status !== 410) return; const body = await response.json().catch(() => ({})); if (isEndingRefusal(response.status, body?.code)) window.dispatchEvent(new CustomEvent(DEMO_ENDED_EVENT, { detail: body.code })); };