import { environment } from '../environments/environment'; import { DEMO_ROLES, type DemoRole } from '../utils/demoEntrance'; import { blocksShowTheirText, deriveBody, type AgreementBlock } from '../components/FirstSignInGate/agreementCopy'; import { createDpopProof, thumbprintForKeyPair } from '../utils/dpop'; import { discardDemoKey, getOrCreateForSignIn, indexedDbStorage } from '../utils/dpopKeyStore'; import { withDpopThumbprint } from '../utils/dpopSession'; import { srpPasswordSignIn } from '../utils/srpPasswordSignIn'; import { demoClientFor } from './authStorage'; import type { DemoOperation } from './demoAllowance'; import { claimDemoTab, clearDemoTab, demoClaimsFrom, demoDpopRecord, finishDemoEntry, newTabId, readDemoEntry, readDemoSession, releaseDemoTab, writeDemoEntry, writeDemoSession, type DemoTabSession, } from './demoTab'; // The demo lease client. It asks nothing until the stack names its demo clients and the visitor holds a // Turnstile pass; until then every request answers that the demo is not open. // One absolute allowance for a whole entry, from the press to the opened mount. export const DEMO_ENTRY_BUDGET_MS = 30_000; /** One entry: the opening it belongs to and its allowance. Every wait settles through it. */ export type DemoEntryOperation = DemoOperation; export interface DemoLeaseRequest { role: DemoRole; turnstileToken: string | undefined; agreement: { accepted: true; version: string }; /** Only for an explicit new demo replacing the visit this tab holds: its whole capability. */ replaces?: { leaseId: string; leaseSecret: string }; } // The agreement version the visitor accepted travels with the lease, so the visit records exactly that one. export type DemoLease = { kind: 'leased'; username: string; password: string; leaseId: string; leaseSecret: string; expiresAt: string; agreementVersion?: string; }; // The demo agreement in force, as published in Code Monkeys and served for the entrance to render. export interface DemoAgreementCopy { version: string; title: string; body: string; blocks?: AgreementBlock[] } export type DemoAgreementAnswer = { kind: 'ready'; copy: DemoAgreementCopy } | { kind: 'none' }; const MAX_VERSION_LENGTH = 64; const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; // Copy that would show other words than its own body, or carries no version, is no agreement to accept. const soundCopy = (body: any): DemoAgreementCopy | undefined => { if (!text(body?.version) || body.version.length > MAX_VERSION_LENGTH || !text(body.title) || !text(body.body)) return undefined; if (body.blocks === undefined) return { version: body.version, title: body.title, body: body.body }; if (!Array.isArray(body.blocks) || body.blocks.length === 0) return undefined; try { if (deriveBody(body.blocks) !== body.body || !blocksShowTheirText(body.blocks)) return undefined; } catch { return undefined; } return { version: body.version, title: body.title, body: body.body, blocks: body.blocks }; }; /** How long one read of the agreement, headers and body together, may take before it is a failed read. */ export const DEMO_AGREEMENT_READ_MS = 10_000; /** * The published demo agreement; none when nothing well formed is in force. A failed, late or retired read throws, * so it can be asked again; the response and its body are both awaited within the operation's allowance. */ export const readDemoAgreement = async (operation: DemoOperation): Promise => { const response = await operation.settle(fetch(environment.apiUrl + '/demo/agreement', { cache: 'no-store', signal: operation.signal })); const body = await operation.settle(response.json().catch(() => ({}))); if (response.ok) { const copy = soundCopy(body); return copy ? { kind: 'ready', copy } : { kind: 'none' }; } if (body?.code === 'DEMO_AGREEMENT_UNAVAILABLE' || body?.code === 'DEMO_ORIGIN_REFUSED') return { kind: 'none' }; throw new Error('The demo agreement could not be read.'); }; export type DemoLeaseAnswer = | DemoLease | { kind: 'full'; estimatedFreeInMinutes: number } | { kind: 'agreement-changed' } | { kind: 'unavailable' }; const DEFINITIVE = ['DEMO_ENDED', 'DEMO_ROLE_MISMATCH', 'DEMO_REQUEST_AGREEMENT_MISMATCH', 'DEMO_AGREEMENT_REQUIRED', 'DEMO_AGREEMENT_UNAVAILABLE', 'DEMO_ORIGIN_REFUSED', 'DEMO_UNKNOWN_ROLE', 'DEMO_POOL_FULL']; const postJson = async (operation: DemoEntryOperation, url: string, body: object): Promise<{ status: number; body: any }> => { operation.admit(); const response = await operation.settle(fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', signal: operation.signal, body: JSON.stringify(body), })); return { status: response.status, body: await operation.settle(response.json().catch(() => ({}))) }; }; /** * Asks for a visit. The request identity is written to the tab before anything is sent and kept until a * definitive answer, so a retry after an uncertain answer, a failed sign-in or a reload recovers the same * lease instead of allocating another. A retired or expired entry throws and changes no record. */ export const requestDemoLease = async (request: DemoLeaseRequest, operation: DemoEntryOperation): Promise => { if (!environment.demoCognitoClients || !request.turnstileToken) return { kind: 'unavailable' }; operation.admit(); const kept = readDemoEntry(); const same = kept && kept.role === request.role && kept.agreementVersion === request.agreement.version; const requestId = same ? kept.requestId : newTabId(); writeDemoEntry({ attempt: operation.attempt, requestId, role: request.role, agreementVersion: request.agreement.version, ...(same && kept.lease ? { lease: kept.lease } : {}), }); const { status, body } = await postJson(operation, environment.apiUrl + '/demo/lease', { role: request.role, agreement: request.agreement, turnstileToken: request.turnstileToken, requestId, ...(request.replaces && { replaces: request.replaces }), }); if (status === 201 && body.role === request.role) { const lease = { leaseId: body.leaseId, leaseSecret: body.leaseSecret, expiresAt: body.expiresAt }; if (readDemoEntry()?.attempt === operation.attempt) { writeDemoEntry({ attempt: operation.attempt, requestId, role: request.role, agreementVersion: request.agreement.version, lease }); } return { kind: 'leased', username: body.username, password: body.password, ...lease, agreementVersion: request.agreement.version }; } if (DEFINITIVE.includes(body.code)) finishDemoEntry(operation.attempt); if (body.code === 'DEMO_POOL_FULL') return { kind: 'full', estimatedFreeInMinutes: Number(body.estimatedFreeInMinutes ?? 0) }; if (body.code === 'DEMO_AGREEMENT_REQUIRED') return { kind: 'agreement-changed' }; if (DEFINITIVE.includes(body.code)) return { kind: 'unavailable' }; // Closed by configuration: nothing was leased, and the request is kept for when entry opens. if (status === 503 && body.code === 'DEMO_UNAVAILABLE') return { kind: 'unavailable' }; // A refused or uncertain challenge leased nothing; the same request is sent again with a fresh token. throw new Error('The demo could not confirm the visit.'); }; export type DemoRoleAnswer = DemoLease | { kind: 'ended' } | { kind: 'unavailable' }; /** * Asks for another role's sign-in in the clinic this tab already holds, proven by the visit's own capability. * It never leases a second clinic or lengthens the visit; an uncertain answer throws so the visitor can retry. */ export const requestDemoRole = async ( held: { leaseId: string; leaseSecret: string }, role: DemoRole, operation: DemoEntryOperation, ): Promise => { const { status, body } = await postJson(operation, environment.apiUrl + '/demo/role', { leaseId: held.leaseId, leaseSecret: held.leaseSecret, role }); if (status === 201 && body.role === role && body.username && body.password) { return { kind: 'leased', username: body.username, password: body.password, leaseId: held.leaseId, leaseSecret: held.leaseSecret, expiresAt: body.expiresAt }; } if (body.code === 'DEMO_ENDED' || body.code === 'DEMO_UNRESOLVED') return { kind: 'ended' }; if (body.code === 'DEMO_UNKNOWN_ROLE' || body.code === 'DEMO_ORIGIN_REFUSED') return { kind: 'unavailable' }; throw new Error('The demo could not confirm the switch.'); }; const mountPath = (role: DemoRole): string => (DEMO_ROLES[role].mount === 'staff' ? '/staff-portal/' : '/'); /** * Signs the leased principal in on this tab only, has the server activate the visit, and records it with the * generation and clinic the server confirmed, then opens the role's own mount. The tab first takes the * visit's exclusive hold, so a copied tab recovering the same lease stops here. The visit's key is this * entry's own; nothing is cleared or written until the visit is confirmed, and a retired or expired entry * throws at its next wait without sending anything more. */ const DEMO_KEY_CLEANUP_WAIT_MS = 1_000; // The visit a commit replaced takes its key along, unless another copy of this tab still holds that visit. const discardReplacedKey = async (previous: DemoTabSession, leaseId: string): Promise => { const sameLease = previous.leaseId === leaseId; if (!sameLease && await claimDemoTab(previous.leaseId).catch(() => 'copy' as const) !== 'held') return; await discardDemoKey(previous.tabId).catch(() => undefined); if (!sameLease) releaseDemoTab(previous.leaseId); }; export const signInDemo = async ( lease: DemoLease, role: DemoRole, operation: DemoEntryOperation, go: (url: string) => void = (url) => window.location.assign(url), sameVisit?: { generation: string; clinicId: string }, ): Promise<'committed' | 'copy' | 'ended'> => { const clientId = demoClientFor(DEMO_ROLES[role].mount === 'staff'); if (!clientId) throw new Error('This stack has no demo sign-in.'); const agreementVersion = lease.agreementVersion; if (!agreementVersion) throw new Error('A demo visit needs the agreement version its visitor accepted.'); if (await operation.settle(claimDemoTab(lease.leaseId)) === 'copy') { finishDemoEntry(operation.attempt); return 'copy'; } const tabId = newTabId(); let committed = false; let creation: Promise = Promise.resolve(); try { const created = getOrCreateForSignIn(indexedDbStorage(demoDpopRecord(tabId))); creation = created; const keyPair = await operation.settle(created); if (!keyPair) throw new Error('This browser cannot hold a demo visit.'); const jkt = await operation.settle(thumbprintForKeyPair(keyPair)); operation.admit(); const tokens = await operation.settle(srpPasswordSignIn({ userPoolId: environment.cognitoUserPoolId, clientId, username: lease.username, password: lease.password, clientMetadata: withDpopThumbprint({}, jkt), mayContinue: operation.isCurrent, })); const claims = demoClaimsFrom(tokens.IdToken); if (!tokens.AccessToken || !tokens.IdToken || !tokens.ExpiresIn || !claims || claims.role !== role) { throw new Error('The demo sign-in did not name this visit.'); } // A role switch stays in the visit it came from; a sign-in for any other visit is not committed. if (sameVisit && claims.generation !== sameVisit.generation) throw new Error('The demo sign-in named another visit.'); const activateUrl = environment.apiUrl + '/demo/activate'; const proof = await operation.settle(createDpopProof({ keyPair, method: 'POST', url: activateUrl })); operation.admit(); const activated = await operation.settle(fetch(activateUrl, { method: 'POST', signal: operation.signal, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + tokens.AccessToken, DPoP: proof }, body: JSON.stringify({ leaseId: lease.leaseId, leaseSecret: lease.leaseSecret }), })); const visit = await operation.settle(activated.json().catch(() => ({}))); if (!activated.ok || visit.role !== role || typeof visit.clinicId !== 'string' || !visit.clinicId) { throw new Error('The demo visit could not start.'); } if (sameVisit && visit.clinicId !== sameVisit.clinicId) throw new Error('The demo switch named another clinic.'); // A visit that expired while its answer was on the way is never committed: nothing is cleared, written or // opened, and its spent lease is not recovered again. const expiresAt = typeof visit.expiresAt === 'string' ? visit.expiresAt : lease.expiresAt; operation.admit(); if (!(Date.parse(expiresAt) > Date.now())) { finishDemoEntry(operation.attempt); return 'ended'; } // The visit this tab held before - its session, keys and demo-owned caches - goes with the commit, never // earlier, and the entry record stays until the new visit is written. const store = window.sessionStorage; const prefix = environment.localStoragePrefix; const previous = readDemoSession(store); clearDemoTab(prefix, store, true); store.setItem(prefix + '_accessToken', tokens.AccessToken); store.setItem(prefix + '_idToken', tokens.IdToken); if (tokens.RefreshToken) store.setItem(prefix + '_refreshToken', tokens.RefreshToken); store.setItem(prefix + '_tokenExpiration', String(Date.now() + tokens.ExpiresIn * 1000)); if (role === 'patient') store.setItem(prefix + '_loggedInAs', 'Patient'); const session: DemoTabSession = { tabId, role, leaseId: lease.leaseId, leaseSecret: lease.leaseSecret, expiresAt, agreementVersion, generation: claims.generation, clinicId: visit.clinicId, }; writeDemoSession(session); committed = true; finishDemoEntry(operation.attempt); if (previous && previous.tabId !== tabId) { // The replaced key gets a moment before the mount opens; a slow store leaves it behind rather than hold the entry. const waited = new Promise((resolve) => { window.setTimeout(resolve, DEMO_KEY_CLEANUP_WAIT_MS); }); await operation.settle(Promise.race([discardReplacedKey(previous, lease.leaseId), waited])); } operation.admit(); go(window.location.origin + mountPath(role)); return 'committed'; } finally { // A key made for a sign-in that was not committed belongs to no visit. It is dropped once its creation has // finished, since giving up on the creation does not stop it from storing the key afterwards. if (!committed) { const drop = () => discardDemoKey(tabId).catch(() => undefined); void creation.then(drop, drop); } } }; export { useDemoTurnstile } from './demoTurnstile';