From 31b4857d8b9955214f4905b67a135d47b7d45ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 19:10:02 +0200 Subject: [PATCH] feat(02-12.1): carry and verify the record context from the browser - attach the asserted context to every same-origin request inside the three fetch entries, so no call site classifies itself - discard a response whose echoed context contradicts the adopted one; serve one that carries no echo, which is what keeps the public poll and every actor surface working - adopt from /api/auth/me and the switch response, and from nothing else - send the epoch on every switch and retry once after reconciling - hold and reconcile on the 409 instead of leaving the record --- .../record-session-fence-compat.test.ts | 191 +++++++++++++++++ .../record-session-fence-guard.test.ts | 6 +- .../layout/record-session-fence-bridge.tsx | 80 +++++++ ...search-mode-acknowledgment-dialog.test.tsx | 1 + .../__tests__/take-all-due.test.tsx | 15 +- .../__tests__/use-medication-intake.test.ts | 11 + src/components/providers.tsx | 2 + .../__tests__/use-record-capabilities.test.ts | 70 +++++- src/hooks/use-account-switch.ts | 69 +++++- src/hooks/use-auth.ts | 8 + src/lib/api/__tests__/api-fetch.test.ts | 62 ++++-- .../api/__tests__/record-fence-client.test.ts | 200 +++++++++++++++++ src/lib/api/api-fetch.ts | 25 ++- src/lib/api/record-fence.ts | 201 ++++++++++++++++++ .../query-keys/record-session-transition.ts | 23 ++ 15 files changed, 939 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/record-session-fence-compat.test.ts create mode 100644 src/components/layout/record-session-fence-bridge.tsx create mode 100644 src/lib/api/__tests__/record-fence-client.test.ts create mode 100644 src/lib/api/record-fence.ts diff --git a/src/__tests__/record-session-fence-compat.test.ts b/src/__tests__/record-session-fence-compat.test.ts new file mode 100644 index 000000000..50cf8f945 --- /dev/null +++ b/src/__tests__/record-session-fence-compat.test.ts @@ -0,0 +1,191 @@ +/** + * FENCE-AC-10 — the only recovery a pre-fence browser tab has, frozen. + * + * A tab open at deploy time that is inside or after a shared-record context + * sends no fence header, so the server refuses it with 403 + * `sharing.access.denied`. That is not an arbitrary choice of code: it is the + * one code the CURRENTLY DEPLOYED bundle already acts on. `SharedRecordGrantLossBridge` + * is mounted globally, subscribes to the whole query cache, and on the first + * errored query carrying that code leaves the record and performs a full + * document navigation — which serves the fence-aware bundle. One reload, + * self-healing, no new code in a bundle nobody can edit any more. + * + * Every link in that chain is load-bearing and every one of them looks + * removable to somebody tidying up. So each is asserted here, with a non-zero + * match first, and the reason written down. + * + * The fourth leg is the opposite assertion and matters as much: the 409 + * `sharing.session.changed` must NOT be in the set the grant-loss bridge fires + * on. That code means "reconcile, do not leave"; routing it into a bridge that + * hard-navigates out of the record would turn a one-frame hold into an + * eviction, and would let one confused tab yank every other tab of the same + * browser out of a record nobody revoked. + * + * Break it by adding `sharing.session.changed` to the bridge's predicate. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +const SWITCH_HOOK = read("hooks/use-account-switch.ts"); +const PROVIDERS = read("components/providers.tsx"); +const GRANT_LOSS_BRIDGE = read( + "components/layout/shared-record-grant-loss-bridge.tsx", +); +const FENCE_BRIDGE = read("components/layout/record-session-fence-bridge.tsx"); + +describe("FENCE-AC-10 a pre-fence tab can still recover", () => { + it("FENCE-AC-10 still keys the grant-loss predicate on sharing.access.denied", () => { + // Non-zero proof first: an emptied file would satisfy a bare `toContain` + // check on nothing. + expect(SWITCH_HOOK.length).toBeGreaterThan(1000); + expect(SWITCH_HOOK).toMatch( + /SHARING_ACCESS_DENIED\s*=\s*"sharing\.access\.denied"/, + ); + expect(SWITCH_HOOK).toMatch( + /export function isSharingAccessDenied[\s\S]{0,200}SHARING_ACCESS_DENIED/, + ); + }); + + it("FENCE-AC-10 still mounts the grant-loss bridge globally", () => { + expect(PROVIDERS).toContain("SharedRecordGrantLossBridge"); + expect(PROVIDERS).toMatch(//); + // And the bridge still subscribes rather than asking call sites. + expect(GRANT_LOSS_BRIDGE).toContain("subscribeToGrantLoss"); + expect(SWITCH_HOOK).toMatch( + /subscribeToGrantLoss[\s\S]{0,600}isSharingAccessDenied\(event\.query\.state\.error\)/, + ); + }); + + it("FENCE-AC-10 still leaves the record with a full document navigation", () => { + // `router.push` would not do: a soft navigation keeps the in-memory cache, + // every React tree holding a rendered value, and — critically here — the + // PRE-FENCE JavaScript bundle. The reload is what serves the new one. + expect(SWITCH_HOOK).toMatch( + /async function landOnRecord[\s\S]{0,400}window\.location\.assign\(destination\)/, + ); + expect(SWITCH_HOOK).toMatch( + /useLeaveSharedRecordOnGrantLoss[\s\S]{0,700}await landOnRecord\(\)/, + ); + }); + + it("FENCE-AC-10 does NOT route the 409 into the grant-loss bridge", () => { + // The predicate the bridge fires on. + const predicate = /subscribeToGrantLoss\(([\s\S]*?)\n\}/.exec(SWITCH_HOOK); + expect(predicate).not.toBeNull(); + const body = predicate![1]; + expect(body.length).toBeGreaterThan(50); + expect(body).toContain("isSharingAccessDenied"); + expect(body).not.toContain("isRecordSessionChanged"); + expect(body).not.toContain("sharing.session.changed"); + // The grant-loss bridge component itself must not learn about it either. + expect(GRANT_LOSS_BRIDGE).not.toContain("sharing.session.changed"); + expect(GRANT_LOSS_BRIDGE).not.toContain("isRecordSessionChanged"); + }); + + it("FENCE-AC-10 routes the 409 into a reconciling bridge instead", () => { + // The positive control for the leg above: without it, "the 409 is not in + // the grant-loss bridge" would pass for a release that dropped the 409 + // handling altogether. + expect(PROVIDERS).toMatch(//); + expect(FENCE_BRIDGE).toContain("isRecordSessionChanged"); + expect(FENCE_BRIDGE).toContain("holdForRecordSessionReconcile"); + expect(FENCE_BRIDGE).toContain("onRecordFenceMismatch"); + // It reconciles; it does not leave. + expect(FENCE_BRIDGE).not.toContain("landOnRecord"); + expect(FENCE_BRIDGE).not.toContain("window.location.assign"); + }); + + it("FENCE-AC-10 keeps the two codes distinct at the source", () => { + expect(SWITCH_HOOK).toMatch( + /SHARING_SESSION_CHANGED\s*=\s*"sharing\.session\.changed"/, + ); + expect(SWITCH_HOOK).toMatch( + /export function isRecordSessionChanged[\s\S]{0,200}SHARING_SESSION_CHANGED/, + ); + }); +}); + +describe("FENCE-AC-10 the browser adopts a context from exactly two responses", () => { + it("FENCE-AC-10 has exactly two callers of adoptRecordFenceState", () => { + // A third adoption path would be a third source of truth about which + // record this browser is on — and it would arrive on the very responses + // whose context is in doubt. The 409 body therefore carries an error code + // and nothing else, by construction. + const CONSUMERS = [ + // `GET /api/auth/me`, the bootstrap. + "hooks/use-auth.ts", + // `POST /api/account/switch`, the only other canonical resolver. + "hooks/use-account-switch.ts", + ]; + + const callers = CONSUMERS.filter((rel) => + /adoptRecordFenceState\(/.test(read(rel)), + ); + expect(callers).toEqual(CONSUMERS); + + // And no third file calls it. The declaring module is allowed to name it. + for (const rel of [ + "components/layout/record-session-fence-bridge.tsx", + "components/layout/shared-record-grant-loss-bridge.tsx", + "lib/api/api-fetch.ts", + ]) { + expect(read(rel)).not.toContain("adoptRecordFenceState"); + } + }); + + it("FENCE-AC-10 adopts before it releases a hold", () => { + // A hold released onto an unadopted context puts the shell back on screen + // while every subsequent request still asserts `bootstrap`. + const src = read("hooks/use-auth.ts"); + const adopt = src.indexOf("adoptRecordFenceState(data.recordSession)"); + const settle = src.indexOf("settleRecordSessionTransition("); + const scope = src.indexOf("setRecordScope("); + expect(adopt).toBeGreaterThan(-1); + expect(settle).toBeGreaterThan(-1); + expect(scope).toBeGreaterThan(-1); + expect(adopt).toBeLessThan(scope); + expect(adopt).toBeLessThan(settle); + }); + + it("FENCE-AC-10 names the epoch on every switch", () => { + // A fence-aware client never omits `expectedEpoch`; the server's + // unconditional arm is reachable only by a bundle that predates the fence. + expect(SWITCH_HOOK).toMatch( + /apiPost\([\s\S]{0,200}expectedEpoch:/, + ); + }); +}); + +describe("FENCE-AC-10 the transport attaches the assertion, not the call sites", () => { + it("FENCE-AC-10 wraps all three fetch entries", () => { + const src = read("lib/api/api-fetch.ts"); + const wraps = [...src.matchAll(/withRecordFenceHeaders\(/g)]; + const validates = [...src.matchAll(/validateResponseContext\(/g)]; + // Three entries: `apiFetch`, `apiFetchEnvelope`, `apiFetchRaw`. The raw one + // matters most — ~25 call sites including the Coach SSE stream, several of + // which are record traffic. + expect(wraps.length).toBe(3); + expect(validates.length).toBe(3); + expect(src).toMatch( + /export async function apiFetchRaw[\s\S]{0,900}withRecordFenceHeaders\(init\)/, + ); + }); + + it("FENCE-AC-10 imports the header names from the client-safe contract only", () => { + // Importing the server fence or the acting carrier would pull `next/headers` + // or Prisma into the browser bundle: clean typecheck, failed `pnpm build`. + const src = read("lib/api/record-fence.ts"); + expect(src).toContain('from "@/lib/sharing/record-session-fence-contract"'); + expect(src).not.toContain('from "@/lib/sharing/record-session-fence"'); + expect(src).not.toContain('from "@/lib/auth/acting-carrier"'); + expect(src).not.toContain('from "@/lib/api-handler"'); + expect(src).not.toContain('from "@/lib/db"'); + }); +}); diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts index bb81b3d3f..6f30e434e 100644 --- a/src/__tests__/record-session-fence-guard.test.ts +++ b/src/__tests__/record-session-fence-guard.test.ts @@ -13,7 +13,9 @@ * a carrier or look up a grant; * * the helpers that deliberately do not consult it still do not; * * one file writes the epoch and a closed list reads it; - * * the header names live in four files and no fifth. + * * the header names live in four files and no fifth: declared in the contract + * module, read by the server fence, attached by the browser transport, and + * published by the OpenAPI route module. * * ## What these matchers cannot see * @@ -268,6 +270,8 @@ describe("the header names live in a closed set of files", () => { ); expect(namers.length).toBeGreaterThan(0); expect(namers).toEqual([ + // Attaches them to every same-origin request, and validates the echo. + "lib/api/record-fence.ts", // Publishes them to the clients that have to send them. "lib/openapi/routes/account-sharing.ts", // Declares them. diff --git a/src/components/layout/record-session-fence-bridge.tsx b/src/components/layout/record-session-fence-bridge.tsx new file mode 100644 index 000000000..2630761fd --- /dev/null +++ b/src/components/layout/record-session-fence-bridge.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { onRecordFenceMismatch } from "@/lib/api/record-fence"; +import { fetchMe } from "@/hooks/use-auth"; +import { isRecordSessionChanged } from "@/hooks/use-account-switch"; +import { queryKeys } from "@/lib/query-keys"; +import { holdForRecordSessionReconcile } from "@/lib/query-keys/record-session-transition"; + +/** + * v1.37.0 — hold and reconcile when the server says this browser's record + * context has moved. + * + * Two signals mean the same thing and land in the same place: + * + * * a 409 `sharing.session.changed`, which the fence raises when the asserted + * context does not match the session row; + * * a 2xx whose ECHOED context contradicts the adopted one — a response that + * was in flight across a switch, or a service-worker replay of one cached + * before it. + * + * The response to both is the same and it is NOT the grant-loss response. This + * browser does not leave the record: it holds every tab, re-reads + * `/api/auth/me` network-only, adopts whatever context the server actually + * holds, and releases. `sharing.session.changed` is deliberately absent from + * the set `subscribeToGrantLoss` fires on — handing a reconcilable + * disagreement to a bridge that hard-navigates out of the record would turn a + * one-frame hold into an eviction. + * + * A sibling of `SharedRecordGrantLossBridge` rather than a branch inside it, + * for the reason that file gives for existing at all: the two answer different + * questions ("you have lost this record" versus "we disagree about which record + * this is"), and a single subscriber with a mode flag is how they start + * answering each other's. + */ +export function RecordSessionFenceBridge() { + const queryClient = useQueryClient(); + + useEffect(() => { + /** + * Held, then reconciled. `fetchMe` adopts the context and settles the + * transition itself, so nothing here decides what the browser is allowed + * to render next — the server's answer does. + */ + const reconcile = () => { + holdForRecordSessionReconcile(); + void queryClient.cancelQueries(); + queryClient.clear(); + void queryClient + .fetchQuery({ queryKey: queryKeys.authMe(), queryFn: fetchMe }) + .catch(() => { + // Offline, or the server is down. The hold stays, which is the + // correct place to be stuck: nothing renders under a context this + // browser cannot prove, and the next successful `/me` releases it. + }); + }; + + // The in-flight / cached-response arm, raised by the transport wrapper. + const disposeMismatch = onRecordFenceMismatch(reconcile); + + // The 409 arm. Watching the query cache rather than asking ~200 call sites + // to handle a status code, for the reason the grant-loss bridge gives: + // a per-call-site handler is a rule the next feature forgets. + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated") return; + if (event.query.state.status !== "error") return; + if (!isRecordSessionChanged(event.query.state.error)) return; + reconcile(); + }); + + return () => { + disposeMismatch(); + unsubscribe(); + }; + }, [queryClient]); + + return null; +} diff --git a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx index a1d4f0da0..e753af7b7 100644 --- a/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx +++ b/src/components/medications/__tests__/research-mode-acknowledgment-dialog.test.tsx @@ -171,6 +171,7 @@ describe("", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: async () => ({ data: { enabled: true } }), // The apiFetch wrapper reads the envelope via `text()`. text: async () => JSON.stringify({ data: { enabled: true } }), diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 2ae62400c..570c52b59 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -399,6 +399,7 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => { function okResponse(id: string) { return { ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id } }), text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id } })), }; @@ -454,7 +455,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => { const fetchMock = vi .fn() .mockResolvedValueOnce(okResponse("evt-a")) - .mockResolvedValueOnce({ ok: false, json: vi.fn(), text: vi.fn() }); + .mockResolvedValueOnce({ + ok: false, + headers: new Headers(), + json: vi.fn(), + text: vi.fn(), + }); vi.stubGlobal("fetch", fetchMock); const queryClient = fakeQueryClient(); @@ -509,7 +515,12 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => { it("reports total failure without invalidating (nothing changed server-side)", async () => { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ ok: false, json: vi.fn(), text: vi.fn() }), + vi.fn().mockResolvedValue({ + ok: false, + headers: new Headers(), + json: vi.fn(), + text: vi.fn(), + }), ); const queryClient = fakeQueryClient(); diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index b73ca1e8d..7d8d500d3 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -109,6 +109,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt-99" } }), // The apiFetch wrapper reads the envelope via `text()`. text: vi @@ -154,6 +155,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }), text: vi .fn() @@ -186,6 +188,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }), text: vi .fn() @@ -212,6 +215,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }), text: vi .fn() @@ -243,6 +247,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt" } }), text: vi.fn().mockResolvedValue(JSON.stringify({ data: { id: "evt" } })), }); @@ -285,6 +290,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }), }); vi.stubGlobal("fetch", fetchMock); @@ -311,6 +317,7 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { "fetch", vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn().mockResolvedValue({ data: {} }), text: vi.fn().mockResolvedValue(JSON.stringify({ data: {} })), }), @@ -334,6 +341,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { it("posts a backdated takenAt against the medication's intake route and returns true", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn(), text: vi.fn().mockResolvedValue(""), }); @@ -367,6 +375,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn(), text: vi.fn().mockResolvedValue(""), }); @@ -394,6 +403,7 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { it("omits takenAt on a skipped log and shows the skipped toast", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), json: vi.fn(), text: vi.fn().mockResolvedValue(""), }); @@ -445,6 +455,7 @@ describe("runUndoIntake — shared soft-delete", () => { it("reverts via DELETE and confirms on success", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + headers: new Headers(), text: vi.fn().mockResolvedValue(""), }); vi.stubGlobal("fetch", fetchMock); diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 4d9808836..48c445aee 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -22,6 +22,7 @@ import { Toaster } from "@/components/ui/sonner"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge"; +import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge"; import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth"; import { useRecordSessionTransition } from "@/hooks/use-record-session-transition"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; @@ -394,6 +395,7 @@ export function Providers({ subscriber notices the stable errorCode and puts the browser back in its own account, rather than asking every call site to. */} + {children} diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 1b4600950..4b59e2648 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -17,7 +17,10 @@ */ import { describe, expect, it } from "vitest"; -import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { + recordContextIsUnproven, + resolveRecordCapabilities, +} from "@/hooks/use-record-capabilities"; import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; const READ_ONLY: AccountAccessEntry = { @@ -155,3 +158,68 @@ describe("resolveRecordCapabilities", () => { expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false); }); }); + +/** + * v1.37.0 — the record context has to be PROVABLE before the controls appear. + * + * `/api/auth/me` publishes the same question from two angles: `accountAccess.active` + * is the re-decided answer (it survived a live-grant pass), and `recordSession.scope` + * is the raw selector the fence compares a request's assertion against. When + * they disagree the session is pointed at a record the grant no longer opens: + * every delegable route will refuse, while `active` being null would otherwise + * make this hook answer "your own record, all controls". That combination + * paints an add button on a page whose every write is about to 403 — the exact + * failure `canAdd` exists to end. + */ +describe("an unprovable record context withholds every control", () => { + it("holds when the raw selector names a record the grant no longer opens", () => { + expect( + recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null), + ).toBe(true); + const held = resolveRecordCapabilities(null, false, false, true); + expect(held.canAdd).toBe(false); + expect(held.canManage).toBe(false); + expect(held.recordSessionPending).toBe(true); + }); + + it("holds when the resolved entry names a record the selector has left", () => { + // The mirror image: the grant resolved, but the session is back on its own + // record. Painting the owner's controls here would be the reverse mix-up. + expect(recordContextIsUnproven({ epoch: 4, scope: null }, READ_ONLY)).toBe( + true, + ); + }); + + it("agrees when both answers name the same record", () => { + expect( + recordContextIsUnproven( + { epoch: 3, scope: READ_ONLY.accountId }, + READ_ONLY, + ), + ).toBe(false); + expect(recordContextIsUnproven({ epoch: 0, scope: null }, null)).toBe( + false, + ); + }); + + it("does not hold when there is no context to cross-check", () => { + // Null is the Bearer transport (no session row, no switch state) and + // undefined is a server image that predates the field. Neither is a + // disagreement, and neither is a reason to blank the app for everybody. + expect(recordContextIsUnproven(null, null)).toBe(false); + expect(recordContextIsUnproven(undefined, READ_ONLY)).toBe(false); + // The positive control for the two above: without it, a + // `recordContextIsUnproven` that always returned false would pass them. + expect(recordContextIsUnproven({ epoch: 1, scope: "someone" }, null)).toBe( + true, + ); + }); + + it("leaves every existing arm untouched when the two agree", () => { + // The fourth argument defaults to false, so every case above this block + // means exactly what it meant before the fence existed. + expect(resolveRecordCapabilities(READ_ONLY)).toEqual( + resolveRecordCapabilities(READ_ONLY, false, false, false), + ); + }); +}); diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index 1d53ee054..e23ddc89a 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -37,6 +37,11 @@ import { useCallback } from "react"; import { toast } from "sonner"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { + adoptRecordFenceState, + currentRecordFenceState, +} from "@/lib/api/record-fence"; +import { fetchMe } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister"; import { queryKeys } from "@/lib/query-keys"; @@ -88,6 +93,20 @@ export function isSharingNotPermitted(error: unknown): boolean { return errorCodeOf(error) === SHARING_NOT_PERMITTED; } +/** + * v1.37.0 — the record-session fence's 409. Not a refusal of access: another + * tab moved the selector first, or this tab's context is older than the row. + * The response is to reconcile through `/api/auth/me` and try again, NOT to + * leave the record — which is why it is deliberately absent from the set + * `subscribeToGrantLoss` fires on below. + */ +export const SHARING_SESSION_CHANGED = "sharing.session.changed"; + +/** True when the server refused because this session's record context moved. */ +export function isRecordSessionChanged(error: unknown): boolean { + return errorCodeOf(error) === SHARING_SESSION_CHANGED; +} + interface SwitchResponse { actingAs: { accountId: string; @@ -95,10 +114,54 @@ interface SwitchResponse { displayName: string | null; access: "READ" | "WRITE"; } | null; + /** + * The context this session is now in. Null when the request carried no + * `expectedEpoch` — which a fence-aware client never does, so in this bundle + * it is always present on a 200. + */ + recordSession: { epoch: number; scope: string | null } | null; } +/** + * Ask the server to move the selector, naming the epoch this browser believes + * it is on, and adopt whatever context comes back. + * + * The epoch turns the write into a compare-and-set: two tabs pressing the + * switcher at the same moment resolve to one monotonic outcome and the loser + * gets a 409 instead of silently overwriting the winner. Adopting the response + * is what lets the very next request prove its context — this endpoint and + * `/api/auth/me` are the only two a client may adopt from. + */ async function postSwitch(accountId: string | null): Promise { - return apiPost("/api/account/switch", { accountId }); + const response = await apiPost("/api/account/switch", { + accountId, + expectedEpoch: currentRecordFenceState()?.epoch ?? 0, + }); + adoptRecordFenceState(response.recordSession); + return response; +} + +/** + * The switch, with one reconcile-and-retry. + * + * A lost compare-and-set is not a failure the person at the keyboard caused or + * can act on: another tab of the same browser moved first. Surfacing that as a + * failed toast would make the switcher look broken while nothing was wrong. So + * a 409 reconciles through a network-only `/api/auth/me` — which re-adopts the + * true epoch — and retries exactly once. A SECOND 409 means the browser is + * genuinely racing itself faster than it can settle, and that does surface, + * because retrying forever would spin rather than tell anybody. + */ +async function postSwitchWithReconcile( + accountId: string | null, +): Promise { + try { + return await postSwitch(accountId); + } catch (error) { + if (!isRecordSessionChanged(error)) throw error; + await fetchMe(); + return postSwitch(accountId); + } } /** @@ -148,7 +211,7 @@ export function useAccountSwitch() { void queryClient.cancelQueries(); queryClient.clear(); try { - await postSwitch(accountId); + await postSwitchWithReconcile(accountId); } catch (error) { // A client error cannot prove that the server left the session alone: // its response may have been lost after the server committed. Keep all @@ -227,7 +290,7 @@ export function useLeaveSharedRecordOnGrantLoss() { void queryClient.cancelQueries(); queryClient.clear(); try { - await postSwitch(null); + await postSwitchWithReconcile(null); commitRecordSessionTransition(transitionId, null); } catch { // The grant-loss path deliberately still reloads, but it cannot claim diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 6f2a87cef..44e884d5a 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; +import { adoptRecordFenceState } from "@/lib/api/record-fence"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; @@ -332,6 +333,13 @@ export async function fetchMe(): Promise { // whatever the switch flow stamped before its reload, the block is what the // resolver will actually honour on the next request, so the mirror tracks it // rather than the client's memory of what it asked for. + // v1.37.0 — adopt the record-session context BEFORE anything releases a + // hold. `/api/auth/me` is one of exactly two responses a client may learn + // its context from, and a hold released onto an unadopted context would put + // the shell back on screen with every subsequent request still asserting + // `bootstrap`. Adoption first means a released hold is always released onto + // a context the next request can prove. + adoptRecordFenceState(data.recordSession); const { accountAccess, status: accountAccessStatus } = resolveAccountAccess(data); if (accountAccessStatus === "invalid") { diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 5cb7abd88..2671b31cc 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -39,6 +39,18 @@ afterEach(() => { vi.unstubAllGlobals(); }); +/** The record-session assertion the transport attached to a `fetch` call. */ +function recordFenceHeadersOf(call: unknown[]): { + epoch: string | null; + scope: string | null; +} { + const headers = new Headers((call[1] as RequestInit | undefined)?.headers); + return { + epoch: headers.get("x-healthlog-record-epoch"), + scope: headers.get("x-healthlog-record-scope"), + }; +} + describe("apiFetch", () => { it("unwraps the data payload from the envelope", async () => { fetchMock.mockResolvedValueOnce( @@ -48,8 +60,17 @@ describe("apiFetch", () => { const data = await apiFetch<{ id: string }>("/api/measurements/m1"); expect(data).toEqual({ id: "m1" }); - expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", { - signal: expect.any(AbortSignal), + // v1.37.0 — `headers` is now always present: the transport attaches the + // record-session assertion to every same-origin request, so no call site + // has to classify itself as record traffic. `objectContaining` rather than + // an exact object so the assertion stays about what THIS test is for. + expect(fetchMock).toHaveBeenCalledWith( + "/api/measurements/m1", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(recordFenceHeadersOf(fetchMock.mock.calls[0])).toEqual({ + epoch: "bootstrap", + scope: "bootstrap", }); }); @@ -62,10 +83,13 @@ describe("apiFetch", () => { signal: controller.signal, }); - expect(fetchMock).toHaveBeenCalledWith("/api/version", { - cache: "no-store", - signal: controller.signal, - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/version", + expect.objectContaining({ + cache: "no-store", + signal: controller.signal, + }), + ); }); it("resolves undefined for a 204 response", async () => { @@ -242,10 +266,13 @@ describe("verb helpers", () => { await apiGet("/api/foo"); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { - method: "GET", - signal: expect.any(AbortSignal), - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/foo", + expect.objectContaining({ + method: "GET", + signal: expect.any(AbortSignal), + }), + ); }); it.each([ @@ -272,10 +299,17 @@ describe("verb helpers", () => { await apiPost("/api/foo"); - expect(fetchMock).toHaveBeenCalledWith("/api/foo", { - method: "POST", - signal: expect.any(AbortSignal), - }); + expect(fetchMock).toHaveBeenCalledWith( + "/api/foo", + expect.objectContaining({ + method: "POST", + signal: expect.any(AbortSignal), + }), + ); + // Still no body and no Content-Type — the fence headers are the only ones. + const [, bodylessInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(bodylessInit.body).toBeUndefined(); + expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull(); }); it("merges caller headers with the JSON Content-Type", async () => { diff --git a/src/lib/api/__tests__/record-fence-client.test.ts b/src/lib/api/__tests__/record-fence-client.test.ts new file mode 100644 index 000000000..831af33dd --- /dev/null +++ b/src/lib/api/__tests__/record-fence-client.test.ts @@ -0,0 +1,200 @@ +/** + * The browser's half of the record-session fence: what it asserts on a request, + * and what it does with the context a response echoes back. + * + * The two halves of the response rule are worth stating as opposites, because + * getting either backwards is a live defect: + * + * * a MISMATCHING echo is discarded — the answer is about a record this + * browser has left, and serving it paints one person's numbers under + * another person's name; + * * an ABSENT echo is served normally — it means the response resolved no + * record scope, which is true of every public route, actor surface, admin + * surface and static response. `/api/version` is polled on a timer through + * `apiGet`, so a discard-on-absence rule would put the browser into a + * permanent hold-and-reconcile loop. + * + * Break it by making `validateResponseContext` discard on absence: the + * `/api/version`-shaped case fails, and no hold is entered. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { + RECORD_EPOCH_HEADER, + RECORD_FENCE_BOOTSTRAP, + RECORD_SCOPE_HEADER, + RECORD_SCOPE_SELF, +} from "@/lib/sharing/record-session-fence-contract"; +import { + RecordContextMismatchError, + __resetRecordFenceForTests, + adoptRecordFenceState, + currentAssertion, + currentRecordFenceState, + onRecordFenceMismatch, + validateResponseContext, + withRecordFenceHeaders, +} from "@/lib/api/record-fence"; + +const OWNER = "owner-account-id"; + +function respond(headers: Record = {}): Response { + return new Response(JSON.stringify({ data: null, error: null }), { + status: 200, + headers, + }); +} + +/** What `/api/version` looks like on the wire: 2xx, no record echo. */ +function publicResponse(): Response { + return respond({ "content-type": "application/json" }); +} + +function echoing(epoch: number, scope: string | null): Response { + return respond({ + [RECORD_EPOCH_HEADER]: String(epoch), + [RECORD_SCOPE_HEADER]: scope ?? RECORD_SCOPE_SELF, + }); +} + +beforeEach(() => { + __resetRecordFenceForTests(); +}); + +afterEach(() => { + __resetRecordFenceForTests(); +}); + +describe("what the browser asserts on a request", () => { + it("sends the bootstrap sentinel before anything has been adopted", () => { + expect(currentAssertion()).toEqual({ + epoch: RECORD_FENCE_BOOTSTRAP, + scope: RECORD_FENCE_BOOTSTRAP, + }); + const init = withRecordFenceHeaders(); + const headers = new Headers(init.headers); + // Present, not absent. Header-absent means exactly one thing on the wire — + // a bundle that predates the fence — and a fence-aware client must never + // be mistaken for one. + expect(headers.get(RECORD_EPOCH_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP); + expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP); + }); + + it("sends the adopted context once /me has answered", () => { + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + const headers = new Headers(withRecordFenceHeaders().headers); + expect(headers.get(RECORD_EPOCH_HEADER)).toBe("4"); + expect(headers.get(RECORD_SCOPE_HEADER)).toBe(OWNER); + }); + + it("spells one's own record as the self sentinel", () => { + adoptRecordFenceState({ epoch: 2, scope: null }); + const headers = new Headers(withRecordFenceHeaders().headers); + expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF); + }); + + it("keeps caller headers and does not overwrite an explicit assertion", () => { + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + const init = withRecordFenceHeaders({ + headers: { + "content-type": "application/json", + [RECORD_EPOCH_HEADER]: "1", + }, + }); + const headers = new Headers(init.headers); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get(RECORD_EPOCH_HEADER)).toBe("1"); + }); + + it("un-adopts on a null context rather than keeping a stale one", () => { + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + adoptRecordFenceState(null); + expect(currentRecordFenceState()).toBeNull(); + expect(currentAssertion().epoch).toBe(RECORD_FENCE_BOOTSTRAP); + }); +}); + +describe("what the browser does with an echoed context", () => { + it("serves a response carrying no echo, and enters no hold", () => { + // The `/api/version` case, named because it is the concrete thing a + // discard-on-absence rule breaks: it is polled on a timer. + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + const hold = vi.fn(); + onRecordFenceMismatch(hold); + + const res = publicResponse(); + expect(validateResponseContext(res)).toBe(res); + expect(hold).not.toHaveBeenCalled(); + }); + + it("serves a response whose echo agrees", () => { + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + const hold = vi.fn(); + onRecordFenceMismatch(hold); + + const res = echoing(4, OWNER); + expect(validateResponseContext(res)).toBe(res); + expect(hold).not.toHaveBeenCalled(); + }); + + it("discards a response delayed across a switch, and enters the hold", () => { + // Acceptance case 6: the response was already past the fence when the + // switch committed, so the server served it honestly under the old context + // and said so. The browser is on epoch 5 now. + adoptRecordFenceState({ epoch: 5, scope: null }); + const hold = vi.fn(); + onRecordFenceMismatch(hold); + + expect(() => validateResponseContext(echoing(4, OWNER))).toThrow( + RecordContextMismatchError, + ); + expect(hold).toHaveBeenCalledTimes(1); + }); + + it("discards on a matching epoch whose scope disagrees", () => { + adoptRecordFenceState({ epoch: 4, scope: OWNER }); + const hold = vi.fn(); + onRecordFenceMismatch(hold); + + expect(() => validateResponseContext(echoing(4, "somebody-else"))).toThrow( + RecordContextMismatchError, + ); + expect(hold).toHaveBeenCalledTimes(1); + }); + + it("serves an echoing response while still unadopted", () => { + // Nothing to contradict: the request itself asserted `bootstrap`, so the + // server already decided whether it could be served at all. + const hold = vi.fn(); + onRecordFenceMismatch(hold); + + const res = echoing(0, null); + expect(validateResponseContext(res)).toBe(res); + expect(hold).not.toHaveBeenCalled(); + }); + + it("does not raise an ApiError, because nothing about the exchange failed", () => { + adoptRecordFenceState({ epoch: 5, scope: null }); + try { + validateResponseContext(echoing(4, OWNER)); + throw new Error("expected a throw"); + } catch (err) { + expect(err).toBeInstanceOf(RecordContextMismatchError); + // A caller branching on `err.status` must not see a status that was + // never refused — the response was a 200. + expect((err as { status?: number }).status).toBeUndefined(); + } + }); + + it("releases the mismatch handler when its disposer runs", () => { + adoptRecordFenceState({ epoch: 5, scope: null }); + const hold = vi.fn(); + const dispose = onRecordFenceMismatch(hold); + dispose(); + + expect(() => validateResponseContext(echoing(4, OWNER))).toThrow( + RecordContextMismatchError, + ); + expect(hold).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index eaa576ae5..55529169a 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -50,6 +50,10 @@ */ import { readError } from "./read-error"; +import { + validateResponseContext, + withRecordFenceHeaders, +} from "./record-fence"; /** HTTP-level failure from an `/api/...` route — non-OK status. */ export class ApiError extends Error { @@ -113,7 +117,9 @@ export async function apiFetch( path: string, init?: RequestInit, ): Promise { - const res = await fetch(path, withDefaultTimeout(init)); + const res = validateResponseContext( + await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))), + ); if (!res.ok) await throwApiError(res); const envelope = await parseEnvelope(res); return (envelope === undefined ? undefined : envelope.data) as T; @@ -127,7 +133,9 @@ export async function apiFetchEnvelope< T = unknown, M = Record, >(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> { - const res = await fetch(path, withDefaultTimeout(init)); + const res = validateResponseContext( + await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))), + ); if (!res.ok) await throwApiError(res); const envelope = await parseEnvelope(res); return { @@ -142,11 +150,20 @@ export async function apiFetchEnvelope< * Performs no `.ok` check, no envelope unwrap, and applies no default * timeout (SSE streams legitimately outlive any fixed window). */ -export function apiFetchRaw( +export async function apiFetchRaw( path: string, init?: RequestInit, ): Promise { - return fetch(path, init); + // The record-session assertion is attached HERE and not at the ~25 call + // sites, several of which are record traffic (the Coach SSE stream, the + // document inbound upload, blob exports). A call site that had to classify + // itself would be wrong the first time a route changed mode. + // + // The echo is validated from headers only — the body is never touched, so an + // SSE stream and a blob download reach their reader intact. + return validateResponseContext( + await fetch(path, withRecordFenceHeaders(init)), + ); } function withJsonBody( diff --git a/src/lib/api/record-fence.ts b/src/lib/api/record-fence.ts new file mode 100644 index 000000000..2426758c5 --- /dev/null +++ b/src/lib/api/record-fence.ts @@ -0,0 +1,201 @@ +/** + * v1.37.0 — the browser's half of the record-session fence. + * + * Every same-origin API request says which record it believes it is addressing, + * and every response that resolved a record scope says which one it was + * actually served under. This module holds both halves: the adopted context, + * the request headers built from it, and the response check. + * + * ─── Where the context comes from ───────────────────────────────────────── + * + * Exactly two responses may be ADOPTED from: `GET /api/auth/me` and + * `POST /api/account/switch`. Both are actor surfaces that resolve canonical + * session state, and both carry the context in the response BODY. Every other + * response's echoed context is used to VALIDATE and never to adopt — which is + * also why the 409 refusal carries only an error code and no epoch: a refusal + * arriving on a request whose context is in doubt is the worst possible third + * source of truth. + * + * ─── Why it is in memory only ───────────────────────────────────────────── + * + * Not mirrored to storage. `/api/auth/me` runs on every boot and is the single + * bootstrap, so a storage mirror would be a second answer that can go stale + * across a deploy — and the `bootstrap` sentinel already makes the + * pre-adoption window safe by producing a 409 rather than a served request. + * + * ─── The validation rule is MISMATCH-ONLY ───────────────────────────────── + * + * A response is discarded only when it carries an echo that CONTRADICTS the + * adopted context. A response carrying NO echo is returned untouched, because + * absence means "this response resolved no record scope" — which is exactly + * true for public routes, actor surfaces, admin, `/me` itself, and static + * responses. + * + * The concrete thing a discard-on-absence rule breaks: + * `src/components/version-poller.tsx` polls the PUBLIC `/api/version` through + * `apiGet` on a timer. That response resolves no record and echoes nothing, so + * a discard-on-absence client would enter the safe hold and fire a network-only + * `/me` on every poll cycle, forever. Do not "tighten" this rule. + * + * The case mismatch-only appears to give up — a service-worker disk-cache hit + * that predates the fence and therefore carries no echo — is closed elsewhere: + * `public/sw.js` names its caches by the app release and its `activate` handler + * deletes every `healthlog-(static|pages|data)-*` cache not in the current set, + * so the deploy that ships the fence evicts every pre-fence cached response + * before one can be served. A POST-fence cached record response does carry the + * echo it was cached with, so replaying it after a switch is a mismatch and is + * discarded. + */ + +import { + RECORD_EPOCH_HEADER, + RECORD_FENCE_BOOTSTRAP, + RECORD_SCOPE_HEADER, + recordScopeHeaderValue, +} from "@/lib/sharing/record-session-fence-contract"; + +/** + * The context this browser has adopted, or null before `/api/auth/me` has told + * it one. + * + * Imported from the contract module and NOT from `record-session-fence.ts` or + * `acting-carrier.ts`: those pull `next/headers` and Prisma, which typechecks + * cleanly in a client module and fails `pnpm build`. + */ +export interface AdoptedRecordContext { + epoch: number; + scope: string | null; +} + +let adopted: AdoptedRecordContext | null = null; + +/** What a mismatching response should trigger. Installed by the app shell. */ +let onContextMismatch: (() => void) | null = null; + +/** + * Adopt the context published by `GET /api/auth/me` or + * `POST /api/account/switch`. + * + * `null` is a real value with a real meaning — the Bearer transport, or a + * server image that predates the field — and it un-adopts, returning the client + * to the `bootstrap` sentinel rather than leaving a stale context in place. + */ +export function adoptRecordFenceState( + context: AdoptedRecordContext | null | undefined, +): void { + adopted = context ?? null; +} + +/** What this browser currently believes. Read by the capability gate. */ +export function currentRecordFenceState(): AdoptedRecordContext | null { + return adopted; +} + +/** + * Install the handler a contradicting response triggers. + * + * A callback rather than a direct import of the transition store, so this + * module stays a transport concern and the app shell owns what "hold and + * reconcile" means. Returns a disposer. + */ +export function onRecordFenceMismatch(handler: () => void): () => void { + onContextMismatch = handler; + return () => { + if (onContextMismatch === handler) onContextMismatch = null; + }; +} + +/** @internal — deterministic module state for unit tests. */ +export function __resetRecordFenceForTests(): void { + adopted = null; + onContextMismatch = null; +} + +/** + * The two header values to send on this request. + * + * Before adoption both are the literal `bootstrap`. That is a PRESENT header, + * which is the whole point: the server's header-absent arm means exactly "a + * bundle that predates the fence" and answers it with the 403 such a bundle + * recovers from, so a fence-aware client must never reach it. On a session that + * never entered a shared record the server's exemption serves `bootstrap` + * normally, so an ordinary sign-in sees no refusal during boot. + */ +export function currentAssertion(): { epoch: string; scope: string } { + if (adopted === null) { + return { epoch: RECORD_FENCE_BOOTSTRAP, scope: RECORD_FENCE_BOOTSTRAP }; + } + return { + epoch: String(adopted.epoch), + scope: recordScopeHeaderValue(adopted.scope), + }; +} + +/** + * Attach the assertion to a request. + * + * Applied inside `apiFetch` / `apiFetchEnvelope` / `apiFetchRaw` rather than at + * call sites, so no caller has to classify its own request as record traffic — + * a classification that would be wrong the first time a route changed mode. A + * caller that set the headers itself keeps them: an explicit value wins, the + * same posture `withDefaultTimeout` takes for `signal`. + */ +export function withRecordFenceHeaders(init?: RequestInit): RequestInit { + const headers = new Headers(init?.headers); + const assertion = currentAssertion(); + if (!headers.has(RECORD_EPOCH_HEADER)) { + headers.set(RECORD_EPOCH_HEADER, assertion.epoch); + } + if (!headers.has(RECORD_SCOPE_HEADER)) { + headers.set(RECORD_SCOPE_HEADER, assertion.scope); + } + return { ...init, headers }; +} + +/** + * Discard a response served under a record context this browser is no longer + * on. + * + * Returns the response untouched when it carries no echo, or when the echo + * agrees. Throws when it contradicts — and triggers the safe hold on the way + * out, so the caller's error path and the browser's reconciliation are one + * event rather than two that can disagree. + * + * Read from headers only. Never consume the body here: `apiFetchRaw` hands the + * response to SSE readers and blob downloads that need the stream intact. + */ +export function validateResponseContext(res: Response): Response { + const echoedEpoch = res.headers.get(RECORD_EPOCH_HEADER); + const echoedScope = res.headers.get(RECORD_SCOPE_HEADER); + + // No echo: this response resolved no record scope. True, safe, and the + // common case — see the version-poller note above. + if (echoedEpoch === null || echoedScope === null) return res; + + // Not adopted yet: there is nothing to contradict. The request itself + // asserted `bootstrap`, so the server already decided whether to serve it. + if (adopted === null) return res; + + const expected = currentAssertion(); + if (echoedEpoch === expected.epoch && echoedScope === expected.scope) { + return res; + } + + onContextMismatch?.(); + throw new RecordContextMismatchError(); +} + +/** + * A response arrived describing a record this browser has left. + * + * Deliberately not an `ApiError`: nothing about the HTTP exchange failed, and a + * caller branching on `err.status` must not see a status that was never + * refused. What happened is that the answer is about somebody else's record, + * and the only safe thing to do with it is to drop it. + */ +export class RecordContextMismatchError extends Error { + constructor() { + super("Response describes a record this session has left"); + this.name = "RecordContextMismatchError"; + } +} diff --git a/src/lib/query-keys/record-session-transition.ts b/src/lib/query-keys/record-session-transition.ts index e3db5ec33..403b5ec79 100644 --- a/src/lib/query-keys/record-session-transition.ts +++ b/src/lib/query-keys/record-session-transition.ts @@ -245,6 +245,29 @@ export function resolveUnknownRecordSessionTransition(id: string): void { publish({ id, phase: "resolving", at: Date.now() }); } +/** + * v1.37.0 — hold every tab because a response contradicted the record context + * this browser is on, with no transition of our own to attach it to. + * + * The switch paths above always hold FIRST and get an id back. This one starts + * from the other end: a 409 from the record-session fence, or a 2xx whose + * echoed context disagrees with the adopted one, means the server has moved and + * nobody told us. There is no prior id because this browser did not begin the + * transition — another tab did, or the referential action behind an account + * deletion did, or an operator did. + * + * A narrow export rather than a second store: `resolving` already means exactly + * "held, outcome unknown, `/me` decides", and `expectedScope` is deliberately + * left unset so `settleRecordSessionTransition` releases on whatever the server + * turns out to say rather than on a scope this browser guessed. + */ +export function holdForRecordSessionReconcile(): void { + seed(); + ensureTransport(); + if (transition.phase === "resolving") return; + publish({ id: makeId(), phase: "resolving", at: Date.now() }); +} + /** Release only the `/me` response that confirms the committed record. */ export function settleRecordSessionTransition( resolvedScope: string | null,