// The original nonce and server-issued comparison scope stay together until that create is confirmed. const KEY_PREFIX = 'ahr:visit-start-operation:'; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; export interface VisitStartScope { viewerId: string; practiceId: string; patientId: string; rowKey: string } export interface VisitStartProof { version: 1; operationId: string; scope: string } export class VisitStartScopeUnconfirmedError extends Error { constructor() { super('The original visit start cannot be confirmed here'); this.name = 'VisitStartScopeUnconfirmedError'; } } export const isVisitStartRuntimeScope = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); export const isVisitStartOperationId = (value: unknown): value is string => typeof value === 'string' && UUID_V4.test(value); /** The operation held for this start, or why none can be: storage refused it, or what it holds cannot be read. */ export type VisitStartCustody = | { state: 'held'; operationId: string; runtimeScope: string } | { state: 'unavailable'; reason: 'not-kept' | 'unreadable' | 'scope-unavailable' | 'scope-changed' | 'legacy-unproved' }; const keyOf = (scope: VisitStartScope): string => KEY_PREFIX + [scope.viewerId, scope.practiceId, scope.patientId, scope.rowKey].map(encodeURIComponent).join(':'); export const decodeVisitStartProof = (value: unknown, operationId: string): VisitStartProof | undefined => { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const proof = value as Record; return proof.version === 1 && proof.operationId === operationId && isVisitStartRuntimeScope(proof.scope) && Object.keys(proof).sort().join() === 'operationId,scope,version' ? { version: 1, operationId, scope: proof.scope } : undefined; }; const readProof = (raw: string, operationId: string): VisitStartProof | undefined => { try { return decodeVisitStartProof(JSON.parse(raw), operationId); } catch { return undefined; } }; /** An older unknown nonce is never assigned today's scope, even if today's read succeeds. */ export function visitStartOperation(scope: VisitStartScope, runtimeScope?: string): VisitStartCustody { try { if (Object.values(scope).some(value => !value || value.length > 2048)) return { state: 'unavailable', reason: 'not-kept' }; const key = keyOf(scope), stored = window.sessionStorage.getItem(key), raw = window.sessionStorage.getItem(key + '.runtime'); if (stored !== null) { if (!UUID_V4.test(stored)) return { state: 'unavailable', reason: 'unreadable' }; if (raw === null) return { state: 'unavailable', reason: 'legacy-unproved' }; const proof = readProof(raw, stored); if (!proof) return { state: 'unavailable', reason: 'unreadable' }; if (!isVisitStartRuntimeScope(runtimeScope)) return { state: 'unavailable', reason: 'scope-unavailable' }; if (proof.scope !== runtimeScope) return { state: 'unavailable', reason: 'scope-changed' }; return { state: 'held', operationId: stored, runtimeScope: proof.scope }; } if (raw !== null) return { state: 'unavailable', reason: 'unreadable' }; if (!isVisitStartRuntimeScope(runtimeScope)) return { state: 'unavailable', reason: 'scope-unavailable' }; const minted = crypto.randomUUID(), serialized = JSON.stringify({ version: 1, operationId: minted, scope: runtimeScope }); window.sessionStorage.setItem(key, minted); if (window.sessionStorage.getItem(key) !== minted) return { state: 'unavailable', reason: 'not-kept' }; window.sessionStorage.setItem(key + '.runtime', serialized); if (window.sessionStorage.getItem(key + '.runtime') !== serialized || window.sessionStorage.getItem(key) !== minted) return { state: 'unavailable', reason: 'not-kept' }; return { state: 'held', operationId: minted, runtimeScope }; } catch { return { state: 'unavailable', reason: 'not-kept' }; } } /** The start is confirmed: only that same operation is let go, so a later, separate start is a new one. */ export function settleVisitStartOperation(scope: VisitStartScope, operationId: string, runtimeScope?: string): void { const key = keyOf(scope); try { if (!isVisitStartRuntimeScope(runtimeScope) || window.sessionStorage.getItem(key) !== operationId) return; const raw = window.sessionStorage.getItem(key + '.runtime'); if (raw === null || readProof(raw, operationId)?.scope !== runtimeScope) return; window.sessionStorage.removeItem(key); window.sessionStorage.removeItem(key + '.runtime'); } catch { // Nothing readable to let go. } }