import { environment } from '../../environments/environment'; export type OperationKind = 'populate' | 'repopulate' | 'restore'; export interface SandboxLifecycle { sample: { state: 'none' | 'ready' | 'partial'; counts?: { patients: number; appointments: number; encounters: number; thisWeek: number }; populatedAt?: string }; operation?: { id: string; kind: OperationKind; phase: string; done: number; total: number; startedAt: string; startedBy: string; stale: boolean }; lastError?: { kind: OperationKind; message: string; at: string }; setup?: { state: string; exportedAt?: string; reasons?: string[] }; /** The next scheduled datastore recreation, present only when the server has one recorded. */ recreation?: { scheduledAt: string }; } type Get = (url: string) => Promise; type Post = (url: string, body: unknown) => Promise; export const lifecycleUrl = () => `${environment.sandboxFhirFoundationApi}/sandbox-lifecycle`; export const readLifecycle = async (get: Get): Promise => (await get(lifecycleUrl())) as SandboxLifecycle; export const startSampleData = (post: Post, action: 'populate' | 'repopulate') => post(`${lifecycleUrl()}/sample-data`, { action }); // Restore is asked of the live practice, which exports its setup with the restore intent; the sandbox // applies it under the same lock as sample data. export const restoreFromLive = async (post: Post): Promise => { const receipt = (await post(`${environment.liveFhirFoundationApi}/sandbox-export`, { restore: true })) as { exportedAt?: string }; return receipt.exportedAt ?? ''; }; // A restore is still landing while the sandbox has not yet answered for its export. export const restoreLanding = (lifecycle: SandboxLifecycle, exportedAt: string | undefined): boolean => !!exportedAt && (lifecycle.setup?.exportedAt ?? '') < exportedAt; // Finished means the sandbox applied this export and its sample clearing is done, so the setup can be re-read. export const restoreFinished = (lifecycle: SandboxLifecycle, exportedAt: string | undefined): boolean => !!exportedAt && !restoreLanding(lifecycle, exportedAt) && lifecycle.setup?.state === 'applied' && !lifecycle.operation; export const restoreRefusedBusy = (lifecycle: SandboxLifecycle, exportedAt: string | undefined): boolean => !!exportedAt && lifecycle.setup?.exportedAt === exportedAt && lifecycle.setup.state === 'rejected' && (lifecycle.setup.reasons ?? []).includes('BUSY'); const statusOf = (error: unknown): number | undefined => typeof error === 'object' && error !== null && 'status' in error ? (error as { status?: number }).status : undefined; const codeOf = (error: unknown): string | undefined => { const body = typeof error === 'object' && error !== null && 'body' in error ? (error as { body?: unknown }).body : undefined; return typeof body === 'object' && body !== null && 'code' in body ? String((body as { code?: unknown }).code) : undefined; }; export const BUSY_COPY = 'Another sample data or restore operation is running for this sandbox. Try again when it finishes.'; export const NO_SETUP_COPY = 'Sample data needs a practitioner with a schedule at a location that has a time zone. Add one in this sandbox first.'; export const DENIED_COPY = 'Only an administrator can change the sample data or restore the setup.'; export const FAILED_COPY = 'Sample data could not be finished. Populate sample data to try again.'; export const RESTORE_APPLY_FAILED_COPY = 'The restore from live did not finish, so part of the setup may already be replaced. Restore setup from live again to finish it.'; export const RESTORE_CLEAR_FAILED_COPY = 'The setup was restored from live, but the old sample data was not all cleared. Restore setup from live again to finish it.'; export const REQUEST_FAILED_COPY = 'That could not be started. Nothing has changed; try again.'; export const refusalCopy = (error: unknown): string => { const code = codeOf(error); if (code === 'SANDBOX_BUSY') return BUSY_COPY; if (code === 'SANDBOX_NO_SETUP') return NO_SETUP_COPY; if (statusOf(error) === 403) return DENIED_COPY; return REQUEST_FAILED_COPY; }; export const lastErrorCopy = (lastError: SandboxLifecycle['lastError']): string | null => { if (!lastError) return null; if (lastError.kind === 'restore') return lastError.message === 'CLEAR_FAILED' ? RESTORE_CLEAR_FAILED_COPY : RESTORE_APPLY_FAILED_COPY; return lastError.message === 'NO_SETUP' ? NO_SETUP_COPY : FAILED_COPY; }; export const operationLabel = (operation: NonNullable): string => { if (operation.kind === 'restore') return operation.phase === 'clear' ? 'Clearing sample data after the restore' : 'Restoring setup from live'; if (operation.phase === 'clear') return 'Clearing the previous sample data'; if (operation.phase === 'generate') return `Adding sample patients: ${operation.done} of ${operation.total}`; return operation.kind === 'repopulate' ? 'Repopulating sample data' : 'Populating sample data'; }; // sandbox-request-v1: each populate, repopulate or restore is one original request, settled only by the // supplier's retained exact outcome for it. The shared lifecycle above is display only. export const PROTOCOL = 'sandbox-request-v1'; export type V1Action = OperationKind; type Route = 'destination' | 'source'; const OUTCOMES = ['unknown', 'pending', 'accepted', 'evidence-owed', 'refused', 'completed', 'failed-partial'] as const; const STAGES = ['admission', 'export', 'apply', 'index', 'clear', 'generate', 'settlement'] as const; const REASONS = ['ADMISSION_UNKNOWN', 'SCOPE_CHANGED', 'COMMAND_MISMATCH', 'NOT_PERMITTED', 'NO_SETUP', 'BUSY', 'PRACTICE_CLOSED', 'EFFECT_UNKNOWN', 'EVIDENCE_OWED', 'APPLY_FAILED', 'INDEX_PENDING', 'CLEAR_FAILED', 'GENERATION_CHANGED', 'UNAVAILABLE'] as const; const ACTIONS: readonly V1Action[] = ['populate', 'repopulate', 'restore']; const SAMPLE_PHASES: readonly Stage[] = ['clear', 'generate']; const RESTORE_PHASES: readonly Stage[] = ['export', 'apply', 'index', 'clear']; type Outcome = typeof OUTCOMES[number]; type Stage = typeof STAGES[number]; type Reason = typeof REASONS[number]; export interface V1Observation { protocol: typeof PROTOCOL; requestId: string; action: V1Action; organizationId: string; scopeDigest: string; commandDigest: string; revision: number; outcome: Outcome; stage: Stage; reason: Reason | null; terminal: boolean; canResume: boolean; evidence: null | { revision: number; scopeDigest: string; commandDigest: string; noLateEffects: boolean; completedPhases: Stage[]; source: null | { key: string; versionId: string; sha256: string } }; } export interface Custody { v: 1; protocol: typeof PROTOCOL; requestId: string; action: V1Action; practiceId: string; viewerKey: string; admissionRevision: number; stage: 'sending' | 'admitted' | 'held'; scopeDigest?: string; commandDigest?: string; revision?: number; at: string; } export type Admitted = { ok: true; value: T } | { ok: false }; const no = { ok: false } as const; const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); 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}$/; const HEX64 = /^[0-9a-f]{64}$/; const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; const date = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value)); const oneOf = (set: readonly T[], value: unknown): value is T => typeof value === 'string' && (set as readonly string[]).includes(value); const destinationUrl = () => lifecycleUrl(); const sourceUrl = () => `${environment.liveFhirFoundationApi}/sandbox-export`; // Restore is admitted by the live source, but only the sandbox destination can say it finished. export const commandUrl = (action: V1Action) => (action === 'restore' ? sourceUrl() : `${lifecycleUrl()}/sample-data`); export const checkUrl = (requestId: string, route: Route) => `${route === 'source' ? sourceUrl() : destinationUrl()}?protocol=${PROTOCOL}&requestId=${encodeURIComponent(requestId)}`; export const discoveryUrl = (action: V1Action) => `${action === 'restore' ? sourceUrl() : destinationUrl()}?protocol=${PROTOCOL}&discover=unsettled`; export const commandRoute = (action: V1Action): Route => (action === 'restore' ? 'source' : 'destination'); // The lookup-unavailable envelope says only that nothing could be read; it never sets or replaces identity. export const isUnavailableEnvelope = (body: unknown): boolean => isObject(body) && body.protocol === PROTOCOL && body.outcome === 'unknown' && body.reason === 'UNAVAILABLE' && !('commandDigest' in body); const evidenceOf = (raw: unknown, top: { revision: number; scopeDigest: string; commandDigest: string }): Admitted => { if (raw === null) return { ok: true, value: null }; if (!isObject(raw) || raw.revision !== top.revision || raw.scopeDigest !== top.scopeDigest || raw.commandDigest !== top.commandDigest) return no; if (typeof raw.noLateEffects !== 'boolean' || !Array.isArray(raw.completedPhases) || !raw.completedPhases.every((phase) => oneOf(STAGES, phase))) return no; let source: { key: string; versionId: string; sha256: string } | null = null; if (raw.source !== null) { const s = raw.source; if (!isObject(s) || typeof s.key !== 'string' || !s.key || typeof s.versionId !== 'string' || !s.versionId || typeof s.sha256 !== 'string' || !HEX64.test(s.sha256)) return no; source = { key: s.key, versionId: s.versionId, sha256: s.sha256 }; } return { ok: true, value: { ...top, noLateEffects: raw.noLateEffects, completedPhases: raw.completedPhases as Stage[], source } }; }; // Terminal needs consistent evidence with no late effects; completed needs every owned phase, and a restore its // exact source object from the destination. Anything the supplier did not promise is unavailable, never coerced. const SAMPLE_STAGES: readonly Stage[] = ['admission', 'clear', 'generate', 'settlement']; const RESTORE_STAGES: readonly Stage[] = ['admission', 'export', 'apply', 'index', 'clear', 'settlement']; const REFUSALS: readonly Reason[] = ['SCOPE_CHANGED', 'COMMAND_MISMATCH', 'NOT_PERMITTED', 'NO_SETUP', 'BUSY', 'PRACTICE_CLOSED', 'GENERATION_CHANGED']; // The supported reason for each outcome, held and terminal; a known word in an unsupported pairing confers nothing. const HELD_REASONS: Record = { unknown: ['ADMISSION_UNKNOWN', 'EFFECT_UNKNOWN'], pending: [null, 'INDEX_PENDING'], accepted: [null], 'evidence-owed': ['EVIDENCE_OWED'], refused: REFUSALS, completed: [null, 'EVIDENCE_OWED'], 'failed-partial': ['APPLY_FAILED', 'CLEAR_FAILED', 'EFFECT_UNKNOWN', 'EVIDENCE_OWED'], }; const TERMINAL_REASONS: Partial> = { refused: REFUSALS, completed: [null], 'failed-partial': ['APPLY_FAILED', 'CLEAR_FAILED'], }; // Resume is a resend of the stored command, so it may only be offered while no issued effect is unknown. const RESUMABLE: readonly Outcome[] = ['pending', 'evidence-owed']; // The live source settles a restore only as far as its own export: accepted, terminal, with exactly the export phase // and its exact source object. It never says the sandbox applied anything. export const exportedOnly = (o: V1Observation): boolean => o.action === 'restore' && o.outcome === 'accepted' && o.terminal && o.stage === 'settlement' && o.reason === null && !o.canResume && !!o.evidence?.noLateEffects && !!o.evidence.source && o.evidence.completedPhases.length === 1 && o.evidence.completedPhases[0] === 'export'; const consistent = (o: V1Observation, route: Route): boolean => { if (!(o.action === 'restore' ? RESTORE_STAGES : SAMPLE_STAGES).includes(o.stage)) return false; if (o.reason === 'PRACTICE_CLOSED' && o.action !== 'restore') return false; if (o.reason === 'NO_SETUP' && o.action === 'restore') return false; if (o.outcome === 'completed' && o.action === 'restore' && route === 'source') return false; // The side is the deployment this consumer itself asked, never a field of the answer. if (o.outcome === 'accepted' && o.terminal) return route === 'source' && exportedOnly(o); if (!o.terminal) { if (!HELD_REASONS[o.outcome].includes(o.reason)) return false; return !o.canResume || (RESUMABLE.includes(o.outcome) && o.reason !== 'EFFECT_UNKNOWN' && o.reason !== 'ADMISSION_UNKNOWN'); } if (o.canResume || !o.evidence?.noLateEffects || !(TERMINAL_REASONS[o.outcome] ?? []).includes(o.reason)) return false; if (o.outcome !== 'completed') return true; const phases = o.action === 'restore' ? RESTORE_PHASES : SAMPLE_PHASES; if (!phases.every((phase) => o.evidence!.completedPhases.includes(phase))) return false; return o.action !== 'restore' || !!o.evidence.source; }; type Expected = { practiceId: string } & Partial>; // One validator for every exact observation; what is expected comes from saved custody or, for discovery, nothing // but the practice, so an original is never admitted on a field it did not prove. const admitObservation = (body: unknown, expected: Expected, route: Route): Admitted => { if (!isObject(body) || body.protocol !== PROTOCOL || isUnavailableEnvelope(body)) return no; const { requestId, action, scopeDigest, commandDigest, revision } = body; if (typeof requestId !== 'string' || !UUID_V4.test(requestId) || !oneOf(ACTIONS, action) || body.organizationId !== expected.practiceId) return no; if ((expected.requestId !== undefined && requestId !== expected.requestId) || (expected.action !== undefined && action !== expected.action)) return no; if (typeof scopeDigest !== 'string' || !HEX64.test(scopeDigest) || typeof commandDigest !== 'string' || !HEX64.test(commandDigest)) return no; if ((expected.scopeDigest !== undefined && expected.scopeDigest !== scopeDigest) || (expected.commandDigest !== undefined && expected.commandDigest !== commandDigest)) return no; if (!count(revision) || (expected.revision !== undefined && revision < expected.revision)) return no; if (!oneOf(OUTCOMES, body.outcome) || !oneOf(STAGES, body.stage) || (body.reason !== null && !oneOf(REASONS, body.reason))) return no; if (typeof body.terminal !== 'boolean' || typeof body.canResume !== 'boolean') return no; const evidence = evidenceOf(body.evidence, { revision, scopeDigest, commandDigest }); if (!evidence.ok) return no; const observation: V1Observation = { protocol: PROTOCOL, requestId, action, organizationId: expected.practiceId, scopeDigest, commandDigest, revision, outcome: body.outcome, stage: body.stage, reason: body.reason as Reason | null, terminal: body.terminal, canResume: body.canResume, evidence: evidence.value, }; return consistent(observation, route) ? { ok: true, value: observation } : no; }; export const admitOriginal = (body: unknown, custody: Custody, route: Route): Admitted => admitObservation(body, { practiceId: custody.practiceId, requestId: custody.requestId, action: custody.action, scopeDigest: custody.scopeDigest, commandDigest: custody.commandDigest, revision: custody.revision, }, route); // Discovery answers only the practice's current admission head. Its original is validated in full; a null original // is never proof about a saved one, and a terminal flag alone is not a settled original. export const admitDiscovery = (body: unknown, practiceId: string, route: Route): Admitted<{ admissionRevision: number; unsettled: boolean }> => { if (!isObject(body) || body.protocol !== PROTOCOL || body.organizationId !== practiceId || !count(body.admissionRevision)) return no; if (body.original === null) return { ok: true, value: { admissionRevision: body.admissionRevision, unsettled: false } }; const original = admitObservation(body.original, { practiceId }, route); return original.ok ? { ok: true, value: { admissionRevision: body.admissionRevision, unsettled: !original.value.terminal } } : no; }; // A fresh command needs positive proof that no earlier request from this browser can still arrive. Missing or // first-use custody is not that proof. The custody fence is: it moves this deployment's admission head one past // the revision discovery read, so any earlier command, which carried an older revision, can no longer be admitted. export type FreshStartProof = { kind: 'custody-fence'; admissionRevision: number; scopeDigest?: string }; export const fenceCommand = (previous: number) => ({ protocol: PROTOCOL, command: 'fence-unadmitted' as const, admissionRevision: previous }); const FENCE_KEYS = ['admissionRevision', 'kind', 'organizationId', 'previousAdmissionRevision', 'protocol', 'scopeDigest']; // The fence answer is the proof only whole: this practice, the revision asked about, and exactly one step past it. export const admitFence = (body: unknown, practiceId: string, previous: number): Admitted => { if (!isObject(body) || Object.keys(body).sort().join() !== FENCE_KEYS.join()) return no; if (body.protocol !== PROTOCOL || body.organizationId !== practiceId || body.kind !== 'custody-fence') return no; if (body.previousAdmissionRevision !== previous || body.admissionRevision !== previous + 1) return no; if (typeof body.scopeDigest !== 'string' || !HEX64.test(body.scopeDigest)) return no; return { ok: true, value: { kind: 'custody-fence', admissionRevision: previous + 1, scopeDigest: body.scopeDigest } }; }; // Each deployment has its own admission head: sample data is admitted by the sandbox, a restore by the live source. const fenceDeployment = (action: V1Action): string => (action === 'restore' ? environment.liveFhirFoundationApi : environment.sandboxFhirFoundationApi) ?? ''; const fenceKey = (practiceId: string, viewerKey: string, action: V1Action) => `ak.sandbox.fence.v1:${practiceId}:${viewerKey}:${fenceDeployment(action)}`; // Written before the fence is sent, so a lost answer is still known to have been asked; `proof` is its admitted answer. export interface FenceCustody { v: 1; protocol: typeof PROTOCOL; practiceId: string; viewerKey: string; deployment: string; previousAdmissionRevision: number; proof: FreshStartProof | null; at: string; } export type StoredFence = { state: 'none' } | { state: 'held'; fence: FenceCustody } | { state: 'unreadable' }; export const readFence = (practiceId: string, viewerKey: string, action: V1Action): StoredFence => { let raw: string | null; try { raw = window.localStorage.getItem(fenceKey(practiceId, viewerKey, action)); } catch { return { state: 'unreadable' }; } if (raw === null) return { state: 'none' }; try { const f: unknown = JSON.parse(raw); if (!isObject(f) || f.v !== 1 || f.protocol !== PROTOCOL || f.practiceId !== practiceId || f.viewerKey !== viewerKey || f.deployment !== fenceDeployment(action) || !count(f.previousAdmissionRevision) || !date(f.at)) return { state: 'unreadable' }; const p = f.proof; if (p !== null && !(isObject(p) && p.kind === 'custody-fence' && p.admissionRevision === f.previousAdmissionRevision + 1 && typeof p.scopeDigest === 'string' && HEX64.test(p.scopeDigest))) return { state: 'unreadable' }; return { state: 'held', fence: f as unknown as FenceCustody }; } catch { return { state: 'unreadable' }; } }; const writeFence = (fence: FenceCustody, action: V1Action): void => { window.localStorage.setItem(fenceKey(fence.practiceId, fence.viewerKey, action), JSON.stringify(fence)); }; // A proof is spent by the one command that carries its revision; the saved original then holds everything else. export const spendFence = (practiceId: string, viewerKey: string, action: V1Action): void => { try { window.localStorage.removeItem(fenceKey(practiceId, viewerKey, action)); } catch { /* a stale proof is refused by its revision */ } }; // The stored proof for the head discovery just read, or none. A fence whose answer was lost is proved by discovery // alone, when the head stands exactly one past it with nothing unsettled on it; deleting or reloading proves nothing. export const freshStartProof = (stored: StoredFence, admissionRevision: number): FreshStartProof | null => { if (stored.state !== 'held') return null; const { proof, previousAdmissionRevision } = stored.fence; if (proof) return proof.admissionRevision === admissionRevision ? proof : null; return admissionRevision === previousAdmissionRevision + 1 ? { kind: 'custody-fence', admissionRevision } : null; }; export type FreshStart = { ok: true; value: FreshStartProof } | { ok: false; copy: string | null }; // Fences the head discovery read. The fence is recorded before it is sent and nothing else is sent until its answer is // admitted; a lost, refused or unproved answer leaves the record for discovery to resolve and starts nothing. export async function fenceFreshStart(post: Post, practiceId: string, viewerKey: string, action: V1Action, admissionRevision: number, current: () => boolean): Promise { const asked: FenceCustody = { v: 1, protocol: PROTOCOL, practiceId, viewerKey, deployment: fenceDeployment(action), previousAdmissionRevision: admissionRevision, proof: null, at: new Date().toISOString() }; try { writeFence(asked, action); } catch { return { ok: false, copy: STORAGE_COPY }; } let answer: Admitted = no; try { answer = admitFence(await post(commandUrl(action), fenceCommand(admissionRevision)), practiceId, admissionRevision); } catch { /* lost or refused */ } if (!current()) return { ok: false, copy: null }; if (!answer.ok) return { ok: false, copy: FENCE_HELD_COPY }; try { writeFence({ ...asked, proof: answer.value }, action); } catch { /* the proof in hand is spent at once */ } return answer; } const COUNT_KEYS = ['patients', 'appointments', 'encounters', 'thisWeek'] as const; // The shared lifecycle is checked against the practice it was asked for; an idle answer may omit operation. export const admitLifecycle = (body: unknown, practiceId: string): Admitted => { if (!isObject(body) || body.organizationId !== practiceId) return no; const sample = body.sample; if (!isObject(sample) || !oneOf(['none', 'ready', 'partial'] as const, sample.state)) return no; if (sample.counts !== undefined && !(isObject(sample.counts) && COUNT_KEYS.every((key) => count((sample.counts as Record)[key])))) return no; if (sample.populatedAt !== undefined && !date(sample.populatedAt)) return no; const op = body.operation; if (op !== undefined && (!isObject(op) || typeof op.id !== 'string' || !op.id || !oneOf(ACTIONS, op.kind) || typeof op.phase !== 'string' || !finite(op.done) || !finite(op.total) || !date(op.startedAt) || typeof op.startedBy !== 'string' || typeof op.stale !== 'boolean')) return no; const setup = body.setup; if (setup !== undefined && (!isObject(setup) || !oneOf(['pending', 'indexing', 'applied', 'rejected'] as const, setup.state) || (setup.exportedAt !== undefined && !date(setup.exportedAt)) || (setup.appliedAt !== undefined && !date(setup.appliedAt)) || (setup.reasons !== undefined && !(Array.isArray(setup.reasons) && setup.reasons.every((r) => typeof r === 'string'))))) return no; const lastError = body.lastError; if (lastError !== undefined && (!isObject(lastError) || !oneOf(ACTIONS, lastError.kind) || typeof lastError.message !== 'string' || !date(lastError.at))) return no; if (body.recreation !== undefined && !(isObject(body.recreation) && date(body.recreation.scheduledAt))) return no; return { ok: true, value: body as unknown as SandboxLifecycle }; }; // One nonsecret unsettled original per practice and viewer, written before its first send. const custodyKey = (practiceId: string, viewerKey: string) => `ak.sandbox.request.v1:${practiceId}:${viewerKey}`; export type StoredCustody = { state: 'none' } | { state: 'held'; custody: Custody } | { state: 'unreadable' }; export const readCustody = (practiceId: string, viewerKey: string): StoredCustody => { let raw: string | null; try { raw = window.localStorage.getItem(custodyKey(practiceId, viewerKey)); } catch { return { state: 'unreadable' }; } if (raw === null) return { state: 'none' }; try { const c: unknown = JSON.parse(raw); if (!isObject(c) || c.v !== 1 || c.protocol !== PROTOCOL || typeof c.requestId !== 'string' || !UUID_V4.test(c.requestId) || !oneOf(ACTIONS, c.action) || c.practiceId !== practiceId || c.viewerKey !== viewerKey || !count(c.admissionRevision)) return { state: 'unreadable' }; return { state: 'held', custody: c as unknown as Custody }; } catch { return { state: 'unreadable' }; } }; // Throws when storage refuses; the caller then sends nothing, since an unsaved original could not be recovered. export const writeCustody = (custody: Custody): void => { window.localStorage.setItem(custodyKey(custody.practiceId, custody.viewerKey), JSON.stringify(custody)); }; // Compare-before-clear: only the exact original being settled may remove the entry, never a newer one. export const clearCustody = (settled: Custody): boolean => { const current = readCustody(settled.practiceId, settled.viewerKey); if (current.state !== 'held' || current.custody.requestId !== settled.requestId) return false; const fields = ['action', 'admissionRevision', 'stage', 'at', 'scopeDigest', 'commandDigest', 'revision'] as const; if (fields.some(field => current.custody[field] !== settled[field])) return false; try { window.localStorage.removeItem(custodyKey(settled.practiceId, settled.viewerKey)); return true; } catch { return false; } }; // What an admitted observation teaches custody: the supplier's digests and the newest revision. export const learn = (custody: Custody, observation: V1Observation): Custody => ({ ...custody, stage: 'admitted', scopeDigest: observation.scopeDigest, commandDigest: observation.commandDigest, revision: observation.revision, }); export const newCommand = (custody: Custody) => (custody.action === 'restore' ? { protocol: PROTOCOL, requestId: custody.requestId, restore: true as const, admissionRevision: custody.admissionRevision } : { protocol: PROTOCOL, requestId: custody.requestId, action: custody.action, admissionRevision: custody.admissionRevision }); export const resumeCommand = (custody: Custody) => ({ protocol: PROTOCOL, requestId: custody.requestId, command: 'resume' as const }); export const HELD_COPY = 'We could not confirm whether the sample data started. It may still be running, so nothing new can start until it is checked.'; export const RESTORE_HELD_COPY = 'We could not confirm whether the restore from live started. It may still apply, so nothing new can start until it is checked.'; export const IN_PROGRESS_COPY = 'This request is still in progress. Nothing new can start until it finishes.'; export const UNREADABLE_COPY = 'An earlier request from this browser could not be read back, so nothing new can start here.'; export const STORAGE_COPY = 'This browser could not keep track of the request, so it was not sent.'; export const NOT_SENT_COPY = 'That could not be started, and it was not sent. Try again.'; export const UNSETTLED_COPY = 'Another request is still settling in this sandbox. Try again when it finishes.'; export const CHECK_FAILED_COPY = 'It could not be checked just now.'; export const UNSAVED_COPY = 'This browser could not save what it learned about the request, so nothing can be resumed, finished or started here until it saves.'; export const FENCE_HELD_COPY = 'New sample data or restore requests are paused here until this browser can confirm no earlier request is still on its way.'; export const PRACTICE_CLOSED_COPY = 'The live practice is closed, so its setup cannot be restored.'; export const REFUSED_COPY = 'That was not started.'; export const EFFECT_UNKNOWN_COPY = 'Part of this request may already have been applied, and that could not be confirmed. It stays held until it is checked, and it cannot be resumed from here.'; export const EXPORTED_COPY = 'The live practice exported its setup for this restore, but the sandbox has not confirmed it was restored. Nothing new can start until it is checked.'; export const heldCopy = (action: V1Action, observation: V1Observation | null): string => { if (observation?.reason === 'EFFECT_UNKNOWN') return EFFECT_UNKNOWN_COPY; if (observation && exportedOnly(observation)) return EXPORTED_COPY; return observation && observation.outcome !== 'unknown' ? IN_PROGRESS_COPY : action === 'restore' ? RESTORE_HELD_COPY : HELD_COPY; }; export const terminalCopy = (o: V1Observation): string | null => { if (o.outcome === 'completed') return null; if (o.outcome === 'failed-partial') { if (o.action !== 'restore') return FAILED_COPY; return o.reason === 'CLEAR_FAILED' ? RESTORE_CLEAR_FAILED_COPY : RESTORE_APPLY_FAILED_COPY; } if (o.reason === 'BUSY') return BUSY_COPY; if (o.reason === 'NO_SETUP') return NO_SETUP_COPY; if (o.reason === 'NOT_PERMITTED') return DENIED_COPY; if (o.reason === 'PRACTICE_CLOSED') return PRACTICE_CLOSED_COPY; return REFUSED_COPY; }; export interface CustodyReadAttempt { readonly requestId: string; readonly sequence: number; readonly original: Custody } export interface CustodyRecoveryAnswer { observation: V1Observation; persistenceHeld: boolean; terminal: boolean } interface CustodyRecoveryFloor { custody: Custody; sequence: number; persisted: boolean; settled: boolean; observation: V1Observation; owner: symbol; origin: string; } const custodyRecoveryFloors = new Map(); let custodyRecoverySequence = 0; /** Learned identity survives a refused save; permissions require the same facts saved and read back. */ export function createCustodyRecovery(practiceId: string, viewerKey: string, current: () => boolean) { const origin = JSON.stringify([environment.liveFhirFoundationApi, environment.sandboxFhirFoundationApi]); const owner = Symbol(); const key = (requestId: string) => JSON.stringify([practiceId, viewerKey, origin, requestId]); const live = () => current() && origin === JSON.stringify([environment.liveFhirFoundationApi, environment.sandboxFhirFoundationApi]); const sameOriginal = (a: Custody, b: Custody) => a.requestId === b.requestId && a.action === b.action && a.practiceId === b.practiceId && a.viewerKey === b.viewerKey && a.admissionRevision === b.admissionRevision && a.at === b.at; const effective = (stored: Custody): Custody | undefined => { const known = custodyRecoveryFloors.get(key(stored.requestId))?.custody; if (!date(stored.at) || !oneOf(['sending', 'admitted', 'held'] as const, stored.stage)) return; if (stored.scopeDigest !== undefined && (typeof stored.scopeDigest !== 'string' || !HEX64.test(stored.scopeDigest))) return; if (stored.commandDigest !== undefined && (typeof stored.commandDigest !== 'string' || !HEX64.test(stored.commandDigest))) return; if (stored.revision !== undefined && !count(stored.revision)) return; if (!known) return { ...stored }; if (!sameOriginal(stored, known) || stored.scopeDigest !== undefined && stored.scopeDigest !== known.scopeDigest || stored.commandDigest !== undefined && stored.commandDigest !== known.commandDigest) return; return { ...stored, scopeDigest: known.scopeDigest, commandDigest: known.commandDigest, revision: Math.max(known.revision ?? 0, stored.revision ?? 0) }; }; const read = (requestId: string) => { if (!live()) return; const kept = readCustody(practiceId, viewerKey); return kept.state === 'held' && kept.custody.requestId === requestId ? effective(kept.custody) : undefined; }; return { owns(attempt: CustodyReadAttempt): boolean { const original = read(attempt.requestId); return !!original && sameOriginal(original, attempt.original); }, isLatest(attempt: CustodyReadAttempt): boolean { const original = read(attempt.requestId); return !!original && sameOriginal(original, attempt.original) && (custodyRecoveryFloors.get(key(attempt.requestId))?.sequence ?? 0) <= attempt.sequence; }, begin(requestId: string): CustodyReadAttempt | undefined { const original = read(requestId); if (!original) return; const attempt = { requestId, sequence: ++custodyRecoverySequence, original: { ...original } }; return attempt; }, accept(body: unknown, route: Route, attempt: CustodyReadAttempt): CustodyRecoveryAnswer | undefined { const original = read(attempt.requestId), previous = custodyRecoveryFloors.get(key(attempt.requestId)); if (!original || !sameOriginal(original, attempt.original)) return; const admitted = admitOriginal(body, original, route); if (!admitted.ok || !live()) return; const observation = admitted.value, learned = learn(original, observation); const floor = { custody: learned, sequence: Math.max(attempt.sequence, previous?.sequence ?? 0), persisted: false, settled: false, observation, owner, origin }; custodyRecoveryFloors.set(key(attempt.requestId), floor); try { writeCustody(learned); const confirmed = readCustody(practiceId, viewerKey); floor.persisted = live() && confirmed.state === 'held' && sameOriginal(confirmed.custody, learned) && confirmed.custody.scopeDigest === learned.scopeDigest && confirmed.custody.commandDigest === learned.commandDigest && confirmed.custody.revision === learned.revision && confirmed.custody.stage === learned.stage; } catch { /* The synchronous floor still constrains every later answer. */ } if (!live()) return; // An older transport may strengthen facts, but cannot acquire settlement/Resume permission. const newest = !previous || attempt.sequence >= previous.sequence; if (!newest) return; // An exported-only source answer is terminal for the source alone; the restore stays held for the sandbox's own outcome. if (observation.terminal && !exportedOnly(observation) && floor.persisted) { if (!clearCustody(learned) || !live() || readCustody(practiceId, viewerKey).state !== 'none') return; // Consuming settlement removes permission, not the immutable facts already observed in this realm. floor.persisted = false; floor.settled = true; return { observation, persistenceHeld: false, terminal: true }; } const kept = readCustody(practiceId, viewerKey); if (kept.state === 'none' || kept.state === 'held' && !sameOriginal(kept.custody, learned)) return; return { observation, persistenceHeld: !floor.persisted, terminal: false }; }, // A learned original not yet saved holds every fresh start here, even if its stored row is gone. holds(): boolean { return [...custodyRecoveryFloors.values()].some(floor => !floor.persisted && !floor.settled && floor.origin === origin && floor.custody.practiceId === practiceId && floor.custody.viewerKey === viewerKey); }, canResume(observation: V1Observation | null): boolean { if (!observation?.canResume || !live()) return false; const floor = custodyRecoveryFloors.get(key(observation.requestId)); const kept = readCustody(practiceId, viewerKey); return Boolean(floor?.persisted && floor.owner === owner && floor.observation === observation && kept.state === 'held' && sameOriginal(kept.custody, floor.custody) && kept.custody.revision === observation.revision && kept.custody.scopeDigest === observation.scopeDigest && kept.custody.commandDigest === observation.commandDigest); }, }; }