import { useCallback, useEffect, useRef, useState } from 'react'; import { environment } from '../../environments/environment'; import { enterSandboxMode, reloadPage } from '../../environments/sandboxMode'; import { readDpopJkt } from '../../utils/dpopSession'; import { dpopKeyStore } from '../../utils/dpopKeyStore'; import { isStaffExperience } from '../../utils/mountContext'; import { useAuthServiceContext, useHttpService } from '../../services'; import { prepareSandbox, SandboxNotReadyError, NOT_READY_COPY, RESTORE_INCOMPLETE_COPY } from './prepareSandbox'; import { readCustody, writeCustody, clearCustody, createCustodyRecovery, checkUrl, commandUrl, commandRoute, discoveryUrl, newCommand, resumeCommand, heldCopy, terminalCopy, admitDiscovery, freshStartProof, readFence, fenceFreshStart, spendFence, PROTOCOL, FENCE_HELD_COPY, NOT_SENT_COPY, UNSETTLED_COPY, STORAGE_COPY, UNSAVED_COPY, UNREADABLE_COPY, type Custody, type StoredCustody, type V1Observation, type CustodyReadAttempt, } from './sandboxLifecycle'; import { viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; import { markLanding } from './SandboxLandingNotice'; import { agreementChanged, type SandboxAgreementCopy } from './sandboxAgreement'; import { storedSessionId } from '../../services/sessionEndedElsewhere'; import type { SaveTransport } from '../../utils/saveOperation'; export const RESTORE_FINISHED_COPY = 'The restore from live finished. You can enter Sandbox Mode.'; export const ACK_NOT_RECORDED_COPY = 'We could not record that you agreed, so Sandbox Mode did not open. Nothing has changed. Try again.'; export const SIGN_IN_AGAIN_COPY = 'Your sign-in could not be confirmed, so Sandbox Mode did not open. Sign in again and try again.'; export const AGREEMENT_CHANGED_COPY = 'The Sandbox agreement has changed. Read the new version below and agree again to enter.'; export const canEnterSandbox = async (accessToken: string | null | undefined): Promise => { const jkt = readDpopJkt(accessToken); if (jkt === undefined) return false; const thumbprint = await dpopKeyStore.currentThumbprint(); return thumbprint === jkt; }; export const SANDBOX_KEY_REFUSAL = 'Sandbox Mode needs a browser that can keep a sign-in key. Allow site data and sign in again, or try another browser.'; // Both gates, for every door into the sandbox: `environment.sandboxAvailable` is fixed once at module // load, and the live mount-context read stops a patient seeing a door even if that value were wrong. export const sandboxOffered = (): boolean => environment.sandboxAvailable && isStaffExperience(); type Custodian = { owner: number; epoch: number; practiceId: string; viewerKey: string; current: () => boolean; recovery: ReturnType; }; // Only a restore original belongs to entry; a sample-data one is the panel's. An unreadable row could be either, so it holds entry too. const restoreCustody = (c: Custodian | null): StoredCustody => { if (!c) return { state: 'none' }; const kept = readCustody(c.practiceId, c.viewerKey); return kept.state === 'held' && kept.custody.action !== 'restore' ? { state: 'none' } : kept; }; const retiredError = () => new DOMException('The entry was retired.', 'AbortError'); // The one entry path, shared by the header's mode menu and the dashboard's setup row so the key check // cannot be skipped by whichever door somebody used. A practice the sandbox lacks is copied and applied before entry, and a // failed preparation refuses and stays on live; `beforeEnter` runs only after it succeeds. // // ONE OWNER PER OPENING, SIGN-IN, VIEWER AND PRACTICE. Any change of those, even back to earlier values, starts a new // owner: the sheet reads and is agreed to again, and the earlier owner's requests, outcomes and switch are retired. The // sign-in is the shared session id, read from storage at every check, so a sign-out elsewhere is seen without a render. export const useSandboxEntry = (beforeEnter?: (transport: SaveTransport) => Promise) => { const { getValidToken, wpOrganization, currentUser } = useAuthServiceContext(); const { get, post } = useHttpService(); const [refusal, setRefusal] = useState(null); const [pending, setPending] = useState(false); const [notice, setNotice] = useState(null); const cancelledRef = useRef(false); const userRef = useRef(currentUser); userRef.current = currentUser; type Owner = { session: string | null; user: unknown; viewer: string | undefined; practice: string | undefined; canonical: string | null; epoch: number; id: number; }; const ownerRef = useRef({ session: storedSessionId(), user: currentUser, viewer: currentUser?.fhir?.id, practice: wpOrganization?.current?.id, canonical: writeThroughViewer(), epoch: viewerEpoch(), id: 1, }); // The canonical viewer and its epoch are part of the owner: acting as someone else, even with the same sign-in, // user and practice, is another owner, and so is returning to the first viewer after another. const liveOwner = useCallback(() => { const practice = wpOrganization?.current?.id; const user = userRef.current; const viewer = user?.fhir?.id; const session = storedSessionId(); const canonical = writeThroughViewer(); const epoch = viewerEpoch(); const seen = ownerRef.current; if (seen.session !== session || seen.user !== user || seen.viewer !== viewer || seen.practice !== practice || seen.canonical !== canonical || seen.epoch !== epoch) { ownerRef.current = { session, user, viewer, practice, canonical, epoch, id: seen.id + 1 }; } return ownerRef.current.id; }, [wpOrganization]); const owner = liveOwner(); // The operation whose outcome the sheet shows; a newer one, or a new owner, retires it. const operationRef = useRef(0); const busyRef = useRef(false); // Reset on mount too: StrictMode replays mount effects, and a cleanup left true would refuse every entry. // A restore original saved in this browser for this practice and viewer is recovered only by its own exact outcome. // Its custodian belongs to one owner and viewer epoch; any change retires it and every answer still owed to it. const custodianRef = useRef(null); const custodian = (): Custodian | null => { const id = liveOwner(); const practiceId = wpOrganization?.current?.id; const viewerKey = writeThroughViewer(); const epoch = viewerEpoch(); if (!practiceId || !viewerKey || ownerRef.current.session === null) return null; const kept = custodianRef.current; if (kept?.owner === id && kept.epoch === epoch && kept.practiceId === practiceId && kept.viewerKey === viewerKey) return kept; const current = () => !cancelledRef.current && liveOwner() === id && viewerEpoch() === epoch && writeThroughViewer() === viewerKey && wpOrganization?.current?.id === practiceId; custodianRef.current = { owner: id, epoch, practiceId, viewerKey, current, recovery: createCustodyRecovery(practiceId, viewerKey, current) }; return custodianRef.current; }; const [stored, setStored] = useState(() => restoreCustody(custodian())); const [observation, setObservation] = useState(null); const [checkFailed, setCheckFailed] = useState(false); const checkSeq = useRef({ next: 0, shown: 0 }); const resuming = useRef(false); const resumeAbort = useRef(null); useEffect(() => { cancelledRef.current = false; return () => { cancelledRef.current = true; resumeAbort.current?.abort(); }; }, []); useEffect(() => { operationRef.current += 1; busyRef.current = false; resuming.current = false; resumeAbort.current?.abort(); resumeAbort.current = null; setPending(false); setRefusal(null); setNotice(null); setStored(restoreCustody(custodian())); setObservation(null); setCheckFailed(false); // eslint-disable-next-line react-hooks/exhaustive-deps -- custodian reads only refs and live values; the owner is the boundary }, [owner]); // A held or unreadable restore original keeps the sandbox closed until it is checked to its end. const restoreHeld = (): boolean => { const kept = restoreCustody(custodian()); setStored(kept); return kept.state !== 'none'; }; const begin = () => { const operation = ++operationRef.current; const admitted = owner; // Owner retirement alone: the onboarding door may unmount this sheet as part of a legitimate commit. No // session is no owner: nothing is admitted without a sign-in to bind it to. const ownerRetired = () => liveOwner() !== admitted || ownerRef.current.session === null; const retired = () => cancelledRef.current || ownerRetired() || operationRef.current !== operation; const admit = () => { if (retired()) throw new DOMException('The entry was retired.', 'AbortError'); }; const owned = (send: () => Promise) => { admit(); return send(); }; const ownedPost = (url: string, body: unknown, options: Record = {}) => owned(() => post(url, body, { ...options, admit })); const ownedGet = (url: string, options: Record = {}) => owned(() => get(url, { ...options, admit })); const controller = new AbortController(); const settle = () => { if (ownerRetired()) controller.abort(); }; const ownerAdmit = () => { settle(); if (ownerRetired()) throw new DOMException('The entry was retired.', 'AbortError'); }; // For the onboarding record: owned by the same entry, but it may remove this sheet, so only the owner is checked. const record: SaveTransport = { admit: ownerAdmit, signal: controller.signal }; return { retired, ownerRetired, ownedPost, ownedGet, record }; }; // Agrees to exactly the copy on screen. Answers `changed` when the server holds other copy, so the sheet reads // it again and asks again; a retired entry sends, shows and opens nothing more. const enter = async (copy: SandboxAgreementCopy, copyOwner: number): Promise<'changed' | void> => { if (busyRef.current || copyOwner !== owner) return; if (storedSessionId() === null) { setRefusal(SIGN_IN_AGAIN_COPY); return; } if (restoreHeld()) return; busyRef.current = true; const { retired, ownerRetired, ownedPost, ownedGet, record } = begin(); setPending(true); setRefusal(null); setNotice(null); try { const token = await getValidToken().catch(() => null); if (retired()) return; if (!(await canEnterSandbox(token))) { if (!retired()) setRefusal(SANDBOX_KEY_REFUSAL); return; } if (retired()) return; const organizationId = wpOrganization?.current?.id; if (!organizationId || !environment.sandboxFhirFoundationApi) { setRefusal(NOT_READY_COPY); return; } // EVERY ENTRY IS AGREED TO, AND THE AGREEMENT IS ON THE RECORD FIRST. Asked of the live practice, // where the person is when they choose to enter. A record that cannot be written is a refused // entry: an agreement nobody can show was given is not one. try { await ownedPost(`${environment.liveFhirFoundationApi}/sandbox-acknowledgement`, { version: copy.version, bodyHash: copy.bodyHash }); } catch (error) { if (retired()) return; if (agreementChanged(error)) { setRefusal(AGREEMENT_CHANGED_COPY); return 'changed'; } setRefusal(ACK_NOT_RECORDED_COPY); return; } if (retired()) return; const prepared = await prepareSandbox({ post: ownedPost as never, get: ownedGet as never, liveFhirFoundationApi: environment.liveFhirFoundationApi, sandboxFhirFoundationApi: environment.sandboxFhirFoundationApi, organizationId, }); if (retired() || restoreHeld()) return; // Recording the setup step is not a reason to refuse; a failed preparation is. Once it starts, // entry is committed: the record can hide the door that opened this sheet, so only the owner is // checked after it, never the sheet. await beforeEnter?.(record).catch(() => undefined); // A restore original saved while the setup step was recorded keeps the sandbox closed all the same. if (ownerRetired() || restoreHeld()) return; if (prepared === 'landing') markLanding(sessionStorage); enterSandboxMode(localStorage, reloadPage); } catch (error) { if (!retired()) setRefusal(error instanceof SandboxNotReadyError ? error.message : NOT_READY_COPY); } finally { if (!retired()) { busyRef.current = false; setPending(false); } } }; // A fresh restore is sent only on the live source's own fence proof for the head just read, passed explicitly, and // any original saved in this browser, the panel's included, holds it. A saved original is recovered by Check and Resume. const restoring = useRef(false); const restore = async () => { const c = custodian(); if (restoring.current || resuming.current) return; // Without a custodian no original could be held, so nothing can confirm a fresh restore is safe to send. if (!c) { setNotice(null); setRefusal(FENCE_HELD_COPY); return; } setStored(restoreCustody(c)); if (readCustody(c.practiceId, c.viewerKey).state !== 'none') return; if (c.recovery.holds()) { setRefusal(UNSAVED_COPY); return; } restoring.current = true; setPending(true); setRefusal(null); setNotice(null); const admit = () => { if (!c.current()) throw retiredError(); }; try { let head: ReturnType = { ok: false }; try { head = admitDiscovery(await get(discoveryUrl('restore'), { organizationId: c.practiceId, admit }), c.practiceId, 'source'); } catch { /* not sent */ } if (!c.current()) return; // An unread head cannot be fenced, so nothing can yet confirm that no earlier restore is on its way. if (!head.ok) { setRefusal(FENCE_HELD_COPY); return; } if (head.value.unsettled) { setRefusal(UNSETTLED_COPY); return; } let proof = freshStartProof(readFence(c.practiceId, c.viewerKey, 'restore'), head.value.admissionRevision); if (!proof) { const fenced = await fenceFreshStart((url, body) => post(url, body, { organizationId: c.practiceId, admit }), c.practiceId, c.viewerKey, 'restore', head.value.admissionRevision, c.current); if (!c.current()) return; if (!fenced.ok) { setRefusal(fenced.copy); return; } proof = fenced.value; } const custody: Custody = { v: 1, protocol: PROTOCOL, requestId: crypto.randomUUID(), action: 'restore', practiceId: c.practiceId, viewerKey: c.viewerKey, admissionRevision: proof.admissionRevision, ...(proof.scopeDigest ? { scopeDigest: proof.scopeDigest } : {}), stage: 'sending', at: new Date().toISOString(), }; if (readCustody(c.practiceId, c.viewerKey).state !== 'none' || c.recovery.holds()) { setStored(restoreCustody(c)); return; } try { writeCustody(custody); } catch { setRefusal(STORAGE_COPY); return; } spendFence(c.practiceId, c.viewerKey, 'restore'); setStored(restoreCustody(c)); const attempt = c.recovery.begin(custody.requestId); if (!attempt) return; let sent = false; try { const body = await post(commandUrl('restore'), newCommand(custody), { organizationId: c.practiceId, onSend: () => { sent = true; }, admit: () => { if (!c.current() || !c.recovery.owns(attempt)) throw retiredError(); }, }); settle(body, commandRoute('restore'), attempt, c); } catch { // Only a request that never reached fetch is known unsent; anything later may still arrive. if (!sent) { clearCustody(custody); if (c.current()) { setStored(restoreCustody(c)); setRefusal(NOT_SENT_COPY); } } } } finally { restoring.current = false; if (c.current()) setPending(false); } }; // An answer counts only for the original saved now, checked against what it has already proved; a stale, foreign or // contradicting one is dropped whole. Only an exact destination outcome, cleared by exact compare, settles it. const settle = (body: unknown, route: 'destination' | 'source', attempt: CustodyReadAttempt, c: Custodian): boolean => { if (!c.current()) return false; const answer = c.recovery.accept(body, route, attempt); if (!answer || !c.current()) return false; setStored(restoreCustody(c)); if (!answer.terminal) { setObservation(answer.observation); return true; } setObservation(null); const copy = terminalCopy(answer.observation); setRefusal(copy); setNotice(copy ? null : RESTORE_FINISHED_COPY); return true; }; // Reads the saved original's own outcome, from the sandbox first. A Check never enters, reloads or sends the command. const checkRestore = async (): Promise => { const c = custodian(); if (!c) return; const kept = restoreCustody(c); setStored(kept); if (kept.state !== 'held') { setObservation(null); setCheckFailed(false); return; } const attempt = c.recovery.begin(kept.custody.requestId); if (!attempt) { setObservation(null); setCheckFailed(true); return; } const seq = ++checkSeq.current.next; const admit = () => { if (!c.current()) throw retiredError(); }; const read = async (route: 'destination' | 'source') => { try { return await get(checkUrl(attempt.requestId, route), { organizationId: c.practiceId, admit }); } catch { return undefined; } }; const fresh = () => c.current() && seq > checkSeq.current.shown; let shown = settle(await read('destination'), 'destination', attempt, c); if (!shown && fresh()) shown = settle(await read('source'), 'source', attempt, c); if (!fresh()) return; if (shown) checkSeq.current.shown = seq; setCheckFailed(!shown); }; // Resume resends the saved original's own command, only on its newest saved observation that permits it, and only // while that permission still holds when the request is physically sent. Any answer it cannot admit, or none, leaves // the original held and withdraws Resume until it is checked again. const resumeRestore = async () => { const c = custodian(); const shown = observation; if (resuming.current || !c || !shown || !c.recovery.canResume(shown)) return; const live = readCustody(c.practiceId, c.viewerKey); if (live.state !== 'held' || live.custody.action !== 'restore' || live.custody.requestId !== shown.requestId || live.custody.revision !== shown.revision) return; const attempt = c.recovery.begin(live.custody.requestId); if (!attempt) return; resuming.current = true; setPending(true); setRefusal(null); setNotice(null); const controller = new AbortController(); resumeAbort.current = controller; let answered = false; try { const body = await post(commandUrl('restore'), resumeCommand(live.custody), { organizationId: c.practiceId, signal: controller.signal, admit: (phase?: 'answer') => { if (!c.current() || !c.recovery.owns(attempt) || (phase !== 'answer' && !c.recovery.canResume(shown))) throw retiredError(); }, }); answered = settle(body, commandRoute('restore'), attempt, c); } catch { // Sent or not, the original stays held: only its own outcome can say what happened. } finally { if (c.current()) { if (!answered) setObservation((now) => (now === shown ? null : now)); setStored(restoreCustody(c)); resuming.current = false; setPending(false); } } }; const c = custodian(); const canResume = stored.state === 'held' && !!observation && observation.requestId === stored.custody.requestId && !!c?.recovery.canResume(observation); const restoreStatus = stored.state === 'unreadable' ? UNREADABLE_COPY : stored.state === 'held' ? heldCopy('restore', observation) : null; return { enter, owner, pending, refusal, notice, restore, canRestore: refusal === RESTORE_INCOMPLETE_COPY && stored.state === 'none', restoreHeld: stored.state !== 'none', restoreStatus, checkFailed: stored.state === 'held' && checkFailed, canResume, checkRestore, resumeRestore, }; };