- {accountLabel(account)}
- {/* Meta, so muted: what the grant lets this person do, in the
- words the server resolved rather than a level code. */}
-
- {account.canWrite
- ? t("recordSharing.switcher.canWrite")
- : t("recordSharing.switcher.readOnly")}
-
-
{accountLabel(account)}
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
index 4e925f7f2..e286ae2a3 100644
--- a/src/components/layout/nav-model.ts
+++ b/src/components/layout/nav-model.ts
@@ -2,6 +2,7 @@ import {
Activity,
Bell,
Brain,
+ ClipboardList,
Droplets,
FileScan,
FlaskConical,
@@ -77,6 +78,8 @@ export interface NavDestination {
* owner gave for their own use, so those surfaces stay owner-only in v1.
*/
sharedRecord?: boolean;
+ /** A record-only view, intentionally absent from the actor's own nav. */
+ sharedRecordOnly?: boolean;
}
/**
@@ -170,6 +173,14 @@ export const NAV_DESTINATIONS: ReadonlyArray = [
tourId: "nav-labs",
requiresModule: "labs",
},
+ {
+ href: "/profile",
+ sharedRecord: true,
+ sharedRecordOnly: true,
+ tKey: "settings.sections.anamnesis.title",
+ icon: ClipboardList,
+ tourId: "nav-profile",
+ },
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
@@ -316,6 +327,11 @@ function isNavDestinationVisible(
) {
return false;
}
+ // A shared record's server-resolved scope decides which health-domain doors
+ // exist. The actor's module preferences describe their own dashboard and
+ // must not hide a domain the target record explicitly granted.
+ if (sharedRecord) return true;
+ if (d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (!mounted) return false;
return modules?.[d.requiresModule] !== false;
diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts
index 6d11b18dc..25f00a232 100644
--- a/src/hooks/use-account-switch.ts
+++ b/src/hooks/use-account-switch.ts
@@ -102,10 +102,13 @@ async function postSwitch(accountId: string | null): Promise {
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
* whose order is the safety property.
*/
-async function landOnRecord(accountId: string | null): Promise {
+async function landOnRecord(
+ accountId: string | null,
+ destination = "/",
+): Promise {
setRecordScope(accountId);
await clearOfflineCachesForRecordSwitch();
- window.location.assign("/");
+ window.location.assign(destination);
// The document is being replaced. Nothing after this runs, and the promise
// deliberately never settles so no caller paints a "done" state over a page
// that is on its way out.
@@ -124,15 +127,21 @@ export function useAccountSwitch() {
const { t } = useTranslations();
const queryClient = useQueryClient();
- return useMutation({
+ const mutation = useMutation({
mutationKey: queryKeys.accountSwitchMutation(),
- mutationFn: async (accountId: string | null) => {
+ mutationFn: async ({
+ accountId,
+ destination,
+ }: {
+ accountId: string | null;
+ destination: string;
+ }) => {
await postSwitch(accountId);
// The in-memory cache holds the record we are leaving. Clearing it here
// as well as reloading costs nothing and closes the window in which a
// failed navigation would leave the old entries reachable.
queryClient.clear();
- return landOnRecord(accountId);
+ return landOnRecord(accountId, destination);
},
onError: (error: unknown) => {
toast.error(
@@ -142,6 +151,16 @@ export function useAccountSwitch() {
);
},
});
+
+ return {
+ ...mutation,
+ /** Preserve the established own-record and exit call sites. */
+ mutate: (accountId: string | null) =>
+ mutation.mutate({ accountId, destination: "/" }),
+ /** Enter a scoped record directly at a server-presentable destination. */
+ switchTo: (accountId: string, destination: string) =>
+ mutation.mutate({ accountId, destination }),
+ };
}
/**
diff --git a/src/lib/navigation/shared-record.ts b/src/lib/navigation/shared-record.ts
index 15ba5be41..63a1fdca8 100644
--- a/src/lib/navigation/shared-record.ts
+++ b/src/lib/navigation/shared-record.ts
@@ -18,7 +18,7 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements"],
medications: ["/medications"],
labs: ["/labs"],
- profile: ["/settings/anamnesis"],
+ profile: ["/profile"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
@@ -87,6 +87,13 @@ export function resolveSharedRecordNavigation(
};
}
+/** The first scope-presentable location to load after a record switch. */
+export function sharedRecordLandingHref(
+ sections: readonly ShareDomain[] | null,
+): string {
+ return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
+}
+
/**
* Does the server-resolved scope make this browser route presentable?
*
From b358cfa3b84ed619212f3d963cbe7366e0e8a170 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:03:22 +0200
Subject: [PATCH 15/54] test(02-12): cover record capability hydration
- hold protected content until account capabilities resolve\n- keep public routes outside the protected loading gate
---
.../auth-shell-shared-record-doors.test.tsx | 28 ++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
index 93d4c7d01..f6ac7607b 100644
--- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
+++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
@@ -51,6 +51,7 @@ import type {
const mockAccessRef: { value: AccountAccess } = {
value: { accounts: [], active: null, canSwitch: false },
};
+const mockAuthLoadingRef = { value: false };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -65,7 +66,7 @@ vi.mock("@/hooks/use-auth", () => ({
},
isAuthenticated: true,
isAuthUnknown: false,
- isLoading: false,
+ isLoading: mockAuthLoadingRef.value,
refetch: vi.fn(),
}),
clearCachesForSessionEnd: vi.fn(),
@@ -169,6 +170,31 @@ const DOORS = [
] as const;
describe(" — the owner-only doors", () => {
+ it("holds protected children until record capabilities resolve", () => {
+ mockAuthLoadingRef.value = true;
+ try {
+ const html = render(OWN_RECORD, "/medications");
+
+ expect(html).toContain('data-slot="record-scope-hydration-gate"');
+ expect(html).not.toContain("sentinel-page");
+ expect(html).not.toContain("sentinel-coach-fab");
+ } finally {
+ mockAuthLoadingRef.value = false;
+ }
+ });
+
+ it("does not delay public routes for record capability resolution", () => {
+ mockAuthLoadingRef.value = true;
+ try {
+ const html = render(OWN_RECORD, "/auth/login");
+
+ expect(html).toContain("sentinel-page");
+ expect(html).not.toContain('data-slot="record-scope-hydration-gate"');
+ } finally {
+ mockAuthLoadingRef.value = false;
+ }
+ });
+
it("mounts all three in the caller's own record", () => {
// The half that makes the rest mean something. Without it, a shell that
// had simply dropped the Coach and the tour for everybody would satisfy
From acd96a80e94e0a10eeab64633e6d70c6565118c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:04:26 +0200
Subject: [PATCH 16/54] fix(02-12): gate protected routes on record scope
- resolve account capabilities before protected page queries mount\n- leave public routes outside the record loading gate
---
src/components/layout/auth-shell.tsx | 28 +++++--------------
.../layout/record-scope-hydration-gate.tsx | 19 +++++++++++++
2 files changed, 26 insertions(+), 21 deletions(-)
create mode 100644 src/components/layout/record-scope-hydration-gate.tsx
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index f9948a742..e8a56cb95 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -19,6 +19,7 @@ import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
+import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -225,17 +226,12 @@ export function AuthShell({
);
}
- // Admin pages stay behind the auth gate while `/api/auth/me` is in
- // flight: the role is unknown until the payload lands, and mounting
- // `/admin/*` children early would fire admin queries that 403 for a
- // non-admin before the redirect effect can move them away.
- if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) {
- return (
-
-
- {t("nav.loadingScreen")}
-
- );
+ // Every protected route waits for `/api/auth/me`, not only Settings and
+ // Admin. The payload resolves the active record as well as identity, so a
+ // child mounted before it could issue an actor-scoped read before the shell
+ // knows that a switched record must be refused or scoped differently.
+ if (isLoading) {
+ return ;
}
// Auth RESOLVED as unauthenticated — hold a spinner while the
@@ -249,16 +245,6 @@ export function AuthShell({
);
}
- // While `/api/auth/me` is still in flight the shell renders the full
- // app chrome + children immediately (the chrome components own their
- // null-user skeletons, pages own their data skeletons). This takes the
- // auth round-trip off the first-paint critical path: page-level
- // queries fire in parallel with `/api/auth/me` instead of behind it.
- // `src/proxy.ts` has already refused cookie-less requests to
- // protected routes, so the unauthenticated-flash window is limited to
- // expired/invalid sessions — those resolve into the redirect branch
- // above as soon as the 401 lands.
-
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
return (
diff --git a/src/components/layout/record-scope-hydration-gate.tsx b/src/components/layout/record-scope-hydration-gate.tsx
new file mode 100644
index 000000000..d4595c9d0
--- /dev/null
+++ b/src/components/layout/record-scope-hydration-gate.tsx
@@ -0,0 +1,19 @@
+import { Loader2 } from "lucide-react";
+
+/**
+ * Holds protected route trees until `/api/auth/me` has resolved the active
+ * record and its capabilities. Mounting a child before this point can issue a
+ * request for the actor's record before the shared-record scope is known.
+ */
+export function RecordScopeHydrationGate({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
From e037c1b8c5af64fd44a78347707d55a0390519e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:05:13 +0200
Subject: [PATCH 17/54] test(02-12): admit scoped cycle startup reads
- freeze calendar and insights as cycle read handlers\n- retain the exact record resolver scope matrix
---
src/__tests__/sharing-surface-guard.test.ts | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts
index 8d773b878..f51839ab9 100644
--- a/src/__tests__/sharing-surface-guard.test.ts
+++ b/src/__tests__/sharing-surface-guard.test.ts
@@ -943,6 +943,14 @@ const DELEGABLE_ROUTES: Record = {
domain: "cycle",
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
},
+ "app/api/cycle/calendar/route.ts": {
+ domain: "cycle",
+ why: "The initial cycle calendar read. Its gate resolves tracking eligibility from the substituted record id, and the handler only reads that record's bounded calendar inputs.",
+ },
+ "app/api/cycle/insights/route.ts": {
+ domain: "cycle",
+ why: "The initial cycle insights read. Its rate-limit key and every phase-correlation input use the resolved record id; it creates no provider egress or mutation.",
+ },
"app/api/cycle/day-logs/route.ts": {
domain: "cycle",
why: 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.',
@@ -1799,8 +1807,9 @@ const ACTOR_ROUTES: Record = {
* settings add two guardian-only entries: 194 → 196. Field-specific
* managed-record configuration adds one more: 196 → 197. The bounded
* read-only profile summary makes the next explicit admission: 197 → 198.
+ * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
*/
-const FROZEN_ENTRY_COUNT = 198;
+const FROZEN_ENTRY_COUNT = 200;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
From b53147559a45bf7ada42b15b76b22dbebaf0307f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:05:59 +0200
Subject: [PATCH 18/54] feat(02-12): scope cycle startup reads
- resolve calendar reads against the active record\n- resolve cycle insights against the active record
---
src/app/api/cycle/calendar/route.ts | 4 ++--
src/app/api/cycle/insights/route.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index 0a4c95aad..f5f4d1f51 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -16,7 +16,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -43,7 +43,7 @@ const DEFAULT_FORWARD_DAYS = 180;
const MAX_SPAN_DAYS = 400;
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
+ const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts
index 7a9d5fdfc..ba23e7992 100644
--- a/src/app/api/cycle/insights/route.ts
+++ b/src/app/api/cycle/insights/route.ts
@@ -15,7 +15,7 @@
* is causal.
*/
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -50,7 +50,7 @@ const WINDOW_DAYS = 365;
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
- const { user } = await requireAuth();
+ const { user } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
From a33cde3034fea4242ccae544881a6fb1dd12c0d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:06:36 +0200
Subject: [PATCH 19/54] test(02-12): cover defensive record presentation
- reject inconsistent access levels before chrome can elevate them\n- fall back to the caller record for malformed payloads
---
.../shared-record-navigation.test.tsx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx
index 953e1cb07..3413dc1a3 100644
--- a/src/__tests__/shared-record-navigation.test.tsx
+++ b/src/__tests__/shared-record-navigation.test.tsx
@@ -114,4 +114,22 @@ describe("shared-record navigation", () => {
}),
).toEqual({ access: "manage", recordKind: "managed" });
});
+
+ it("fails closed when a malformed entry reaches presentation", () => {
+ expect(
+ resolveRecordPresentation({
+ level: "manage",
+ canWrite: false,
+ recordKind: "managed",
+ } as never),
+ ).toEqual({ access: "view", recordKind: "self" });
+
+ expect(
+ resolveRecordPresentation({
+ level: "unknown",
+ canWrite: true,
+ recordKind: "shared",
+ } as never),
+ ).toEqual({ access: "view", recordKind: "self" });
+ });
});
From f6b9c8e882d1c716e7bc906620bfcffd8e899bd1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:07:27 +0200
Subject: [PATCH 20/54] fix(02-12): fail closed in record presentation
- retain elevated chrome only for internally consistent payloads\n- fall back to the caller record on malformed access entries
---
src/lib/navigation/record-presentation.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/lib/navigation/record-presentation.ts b/src/lib/navigation/record-presentation.ts
index 3922c0243..b9d0f378b 100644
--- a/src/lib/navigation/record-presentation.ts
+++ b/src/lib/navigation/record-presentation.ts
@@ -18,6 +18,21 @@ export interface RecordPresentation {
export function resolveRecordPresentation(
entry: Pick,
): RecordPresentation {
+ const validRecordKind =
+ entry.recordKind === "shared" || entry.recordKind === "managed";
+ const validAccess =
+ (entry.level === "read" && entry.canWrite === false) ||
+ ((entry.level === "write" || entry.level === "manage") &&
+ entry.canWrite === true);
+
+ // `parseAccountAccess` normally makes this branch unreachable. Keep the
+ // presentation boundary defensive anyway: a stale cache or an untyped
+ // consumer must never turn contradictory data into a stronger affordance or
+ // claim that the browser is inside somebody else's record.
+ if (!validRecordKind || !validAccess) {
+ return { access: "view", recordKind: "self" };
+ }
+
return {
access:
entry.level === "manage"
From 7104578d2770c529efc15405bf7ab73153d7e626 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:12:23 +0200
Subject: [PATCH 21/54] fix(02-12): honor shared scope in module pages
- keep scoped record pages out of actor module redirects\n- suppress unrelated illness reads from document-only records
---
src/app/cycle/page.tsx | 4 +++-
src/app/illness/[id]/page.tsx | 4 +++-
src/app/illness/page.tsx | 4 +++-
src/app/labs/page.tsx | 4 ++--
src/app/medications/page-client.tsx | 5 +++--
src/app/mental-wellbeing/page.tsx | 4 +++-
src/app/mood/page.tsx | 4 ++--
src/components/documents/documents-view.tsx | 10 +++++++---
src/components/illness/use-illness.ts | 3 ++-
9 files changed, 28 insertions(+), 14 deletions(-)
diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx
index a4ab8c9aa..ab7a29e57 100644
--- a/src/app/cycle/page.tsx
+++ b/src/app/cycle/page.tsx
@@ -4,6 +4,7 @@ import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CycleView } from "@/components/cycle/cycle-view";
@@ -22,9 +23,10 @@ import { CycleView } from "@/components/cycle/cycle-view";
*/
export default function CyclePage() {
const { user, isLoading, isAuthenticated } = useAuth();
+ const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
- const enabled = user?.modules?.cycle !== false;
+ const enabled = inSharedRecord || user?.modules?.cycle !== false;
useEffect(() => {
if (isLoading) return;
diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx
index 98e4c174c..c0275553a 100644
--- a/src/app/illness/[id]/page.tsx
+++ b/src/app/illness/[id]/page.tsx
@@ -14,6 +14,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -24,11 +25,12 @@ export default function IllnessEpisodePage({
}) {
const { id } = use(params);
const { user, isAuthenticated, isLoading } = useAuth();
+ const { inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const { t } = useTranslations();
- const enabled = user?.modules?.illness === true;
+ const enabled = inSharedRecord || user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx
index 6fef680cc..9511acf3c 100644
--- a/src/app/illness/page.tsx
+++ b/src/app/illness/page.tsx
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessView } from "@/components/illness/illness-view";
@@ -20,10 +21,11 @@ import { IllnessView } from "@/components/illness/illness-view";
*/
export default function IllnessPage() {
const { user, isLoading, isAuthenticated } = useAuth();
+ const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Born-gated: enabled ONLY when the resolved map says `true`.
- const enabled = user?.modules?.illness === true;
+ const enabled = inSharedRecord || user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 78adf9925..381bb1723 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -41,7 +41,7 @@ export default function LabsPage() {
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
- const { canAdd, canManage } = useRecordCapabilities();
+ const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -60,7 +60,7 @@ export default function LabsPage() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
- const enabled = user?.modules?.labs !== false;
+ const enabled = inSharedRecord || user?.modules?.labs !== false;
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx
index 5799fde99..d7dc51dd4 100644
--- a/src/app/medications/page-client.tsx
+++ b/src/app/medications/page-client.tsx
@@ -168,13 +168,14 @@ export default function MedicationsPageClient() {
// to tapping each card in turn — so withholding it made a caregiver with
// five morning tablets tap five times for nothing. The customize page stays
// withheld; sharing does not cover it at all.
- const { canAdd, canManage } = useRecordCapabilities();
+ const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
// Default-on: an absent key reads as enabled, so the page only hides on an
// explicit `false`.
- const medicationsEnabled = user?.modules?.medications !== false;
+ const medicationsEnabled =
+ inSharedRecord || user?.modules?.medications !== false;
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
diff --git a/src/app/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx
index 970860c68..c5ccc99f2 100644
--- a/src/app/mental-wellbeing/page.tsx
+++ b/src/app/mental-wellbeing/page.tsx
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
@@ -26,9 +27,10 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
*/
export default function MentalWellbeingPage() {
const { user, isLoading, isAuthenticated } = useAuth();
+ const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
- const enabled = user?.modules?.mentalHealth === true;
+ const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
useEffect(() => {
if (isLoading) return;
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx
index 04e02a1c0..8668a82f7 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -21,7 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { user, isAuthenticated, isLoading } = useAuth();
- const { canManage } = useRecordCapabilities();
+ const { canManage, inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -37,7 +37,7 @@ export default function MoodPage() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
- const enabled = user?.modules?.mood !== false;
+ const enabled = inSharedRecord || user?.modules?.mood !== false;
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx
index ca4aa87e0..ac220790c 100644
--- a/src/components/documents/documents-view.tsx
+++ b/src/components/documents/documents-view.tsx
@@ -184,14 +184,15 @@ export function DocumentsView() {
// about it are none of them delegated verbs. Inside somebody else's record
// the vault reads and nothing more: no upload path, no selection, no bulk
// bar, no corpus backfill.
- const { canManage } = useRecordCapabilities();
+ const { canManage, inSharedRecord, sections } = useRecordCapabilities();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const coachLaunch = useCoachLaunch();
- const moduleEnabled = user?.modules?.inboundDocuments === true;
+ const moduleEnabled =
+ inSharedRecord || user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {
@@ -377,7 +378,10 @@ export function DocumentsView() {
);
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
- const episodes = useIllnessEpisodes(true);
+ const episodes = useIllnessEpisodes(
+ true,
+ !inSharedRecord || sections === null || sections.includes("illness"),
+ );
// Content-search coverage: fire the corpus backfill when indexing is
// available and some documents are not yet searchable. The list search
diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts
index 70be6ef08..4bd5dd642 100644
--- a/src/components/illness/use-illness.ts
+++ b/src/components/illness/use-illness.ts
@@ -25,9 +25,10 @@ import type {
} from "./types";
/** Newest-first episode list. */
-export function useIllnessEpisodes(includeResolved = true) {
+export function useIllnessEpisodes(includeResolved = true, enabled = true) {
return useQuery({
queryKey: queryKeys.illnessEpisodes(includeResolved),
+ enabled,
queryFn: () =>
apiGet(
`/api/illness/episodes?includeResolved=${includeResolved}`,
From 407f7cc044af1899ecca6ff20907a60c659ace7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:12:33 +0200
Subject: [PATCH 22/54] test(02-12): exercise scoped record doorways
- seed isolated records for all eight shared domains\n- verify each browser switch lands on its scoped read surface
---
e2e/setup/global-setup.ts | 145 +++++++++++++++++++++-
e2e/setup/test-helpers.ts | 3 +
e2e/v137-sharing-managed-profiles.spec.ts | 101 ++++++++++-----
3 files changed, 215 insertions(+), 34 deletions(-)
diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts
index 80d3765d3..8fb94174d 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -65,6 +65,34 @@ export const E2E_OWNER = {
role: "USER",
} as const;
+/** A separate delegate session for the eight scoped-record browser journeys. */
+export const E2E_SCOPE_DELEGATE = {
+ email: "e2e-scope-delegate@healthlog.test",
+ username: "e2e-scope-delegate",
+ password: "Qz8!Vp4rL2nX7mKs",
+ role: "USER",
+} as const;
+
+/** One non-personal target record per closed sharing domain. */
+export const E2E_SCOPE_RECORDS = [
+ {
+ domain: "measurements",
+ username: "e2e-scope-measurements",
+ href: "/measurements",
+ },
+ {
+ domain: "medications",
+ username: "e2e-scope-medications",
+ href: "/medications",
+ },
+ { domain: "labs", username: "e2e-scope-labs", href: "/labs" },
+ { domain: "profile", username: "e2e-scope-profile", href: "/profile" },
+ { domain: "illness", username: "e2e-scope-illness", href: "/illness" },
+ { domain: "mind", username: "e2e-scope-mind", href: "/mood" },
+ { domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" },
+ { domain: "documents", username: "e2e-scope-documents", href: "/documents" },
+] as const;
+
/**
* Weights seeded one per account, chosen so neither can be mistaken for the
* other in rendered markup and neither collides with a value any other spec
@@ -105,6 +133,11 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateDelegate.json",
);
+export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve(
+ process.cwd(),
+ "e2e/setup/storageStateScopeDelegate.json",
+);
+
async function hashPassword(password: string): Promise {
return hash(password, {
memoryCost: 19456,
@@ -201,6 +234,110 @@ export default async function globalSetup(config: FullConfig): Promise {
],
);
+ // The scoped-record journey uses a dedicated delegate so its preseeded
+ // grants cannot affect the invitation lifecycle exercised by the sharing
+ // journey above. Its own module preferences are explicitly off: a target
+ // record's resolved scope, rather than the actor's preferences, must keep
+ // each granted doorway visible.
+ await pool.query(
+ `INSERT INTO users
+ (id, username, email, password_hash, role, created_at, updated_at,
+ onboarding_completed_at, onboarding_tour_completed,
+ module_preferences_json)
+ VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
+ ON CONFLICT (username) DO UPDATE SET
+ email = EXCLUDED.email,
+ password_hash = EXCLUDED.password_hash,
+ updated_at = EXCLUDED.updated_at,
+ onboarding_completed_at = EXCLUDED.onboarding_completed_at,
+ onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
+ module_preferences_json = EXCLUDED.module_preferences_json`,
+ [
+ cuid(),
+ E2E_SCOPE_DELEGATE.username,
+ E2E_SCOPE_DELEGATE.email,
+ await hashPassword(E2E_SCOPE_DELEGATE.password),
+ now,
+ JSON.stringify({
+ medications: false,
+ labs: false,
+ illness: false,
+ mood: false,
+ mentalHealth: false,
+ inboundDocuments: false,
+ }),
+ ],
+ );
+
+ await pool.query(
+ `DELETE FROM account_grants
+ WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`,
+ [E2E_SCOPE_DELEGATE.username],
+ );
+ await pool.query(
+ `UPDATE sessions SET acting_as_user_id = NULL
+ WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
+ [E2E_SCOPE_DELEGATE.username],
+ );
+
+ for (const record of E2E_SCOPE_RECORDS) {
+ await pool.query(
+ `INSERT INTO users
+ (id, username, email, password_hash, role, created_at, updated_at,
+ onboarding_completed_at, onboarding_tour_completed,
+ module_preferences_json)
+ VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb)
+ ON CONFLICT (username) DO UPDATE SET
+ email = EXCLUDED.email,
+ updated_at = EXCLUDED.updated_at,
+ onboarding_completed_at = EXCLUDED.onboarding_completed_at,
+ onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
+ module_preferences_json = EXCLUDED.module_preferences_json`,
+ [
+ cuid(),
+ record.username,
+ `${record.username}@healthlog.test`,
+ now,
+ JSON.stringify({
+ medications: true,
+ labs: true,
+ illness: true,
+ mood: true,
+ mentalHealth: true,
+ inboundDocuments: true,
+ }),
+ ],
+ );
+
+ await pool.query(
+ `INSERT INTO account_grants
+ (id, grantor_id, grantee_id, access, scope_json, invited_at,
+ accepted_at, created_at)
+ SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3
+ FROM users owner, users delegate
+ WHERE owner.username = $4 AND delegate.username = $5`,
+ [
+ cuid(),
+ JSON.stringify([record.domain]),
+ now,
+ record.username,
+ E2E_SCOPE_DELEGATE.username,
+ ],
+ );
+ }
+
+ // Cycle is delegated to its profile gate rather than module preferences.
+ await pool.query(
+ `INSERT INTO cycle_profiles
+ (id, user_id, cycle_tracking_enabled, created_at, updated_at)
+ SELECT $1, u.id, true, $2, $2
+ FROM users u WHERE u.username = $3
+ ON CONFLICT (user_id) DO UPDATE SET
+ cycle_tracking_enabled = true,
+ updated_at = EXCLUDED.updated_at`,
+ [cuid(), now, "e2e-scope-cycle"],
+ );
+
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
@@ -281,7 +418,7 @@ export default async function globalSetup(config: FullConfig): Promise {
WHERE key LIKE 'sharing:%'`,
);
- // The login bucket, for the same reason. This setup signs in THREE times
+ // The login bucket, for the same reason. This setup signs in FOUR times
// now (the shared jar, the owner, and the sharing journey's own delegate
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
// local runs in a row would otherwise end with a 429 from the fixture
@@ -345,6 +482,12 @@ export default async function globalSetup(config: FullConfig): Promise {
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
// what happens when the journey switches the shared row instead.
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
+
+ await captureAuthState(
+ baseURL,
+ E2E_SCOPE_DELEGATE,
+ SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ );
}
/**
diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts
index 9ae2f7a18..5a72d271f 100644
--- a/e2e/setup/test-helpers.ts
+++ b/e2e/setup/test-helpers.ts
@@ -9,9 +9,12 @@
export {
E2E_USER,
E2E_OWNER,
+ E2E_SCOPE_DELEGATE,
+ E2E_SCOPE_RECORDS,
E2E_DELEGATE_MARKER_KG,
E2E_OWNER_MARKER_KG,
STORAGE_STATE_PATH,
OWNER_STORAGE_STATE_PATH,
DELEGATE_STORAGE_STATE_PATH,
+ SCOPE_DELEGATE_STORAGE_STATE_PATH,
} from "./global-setup";
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 5f4eb88c5..db8f38a57 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -1,39 +1,74 @@
+import type { Page } from "@playwright/test";
+
import { expect, test } from "./setup/test";
+import {
+ E2E_SCOPE_RECORDS,
+ SCOPE_DELEGATE_STORAGE_STATE_PATH,
+} from "./setup/test-helpers";
-import { RELEASE_JOURNEYS } from "../tests/fixtures/v137/e2e-journeys";
+const DOMAIN_READS = {
+ measurements: "/api/measurements",
+ medications: "/api/medications",
+ labs: "/api/labs",
+ profile: "/api/profile/summary",
+ illness: "/api/illness/episodes",
+ mind: "/api/mood-entries",
+ cycle: "/api/cycle/calendar",
+ documents: "/api/documents/inbound",
+} as const;
-test.describe("sharing release journey anchors", () => {
- for (const journey of RELEASE_JOURNEYS) {
- test(`anchor: ${journey.name}`, () => {
- expect(journey.contract).toBe("inventory-only");
+async function openSwitcher(page: Page) {
+ await page.getByRole("button", { name: "User menu" }).first().click();
+ await page.locator('[data-slot="account-switcher-trigger"]').click();
+ await expect(
+ page.locator('[data-slot="account-switcher-menu"]'),
+ ).toBeVisible();
+}
+
+test.describe("scoped sharing browser journeys", () => {
+ test.describe.configure({ mode: "serial" });
+ test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
+
+ test.beforeEach(({}, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "scoped sharing seed has one session and runs once",
+ );
+ });
+
+ for (const record of E2E_SCOPE_RECORDS) {
+ test(`opens only the ${record.domain} record doorway`, async ({ page }) => {
+ await page.goto("/");
+ await openSwitcher(page);
+
+ const entry = page
+ .locator('[data-slot="account-switcher-entry"]')
+ .filter({ hasText: record.username });
+ await expect(entry).toHaveCount(1);
+
+ const read = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname === DOMAIN_READS[record.domain],
+ );
+ await entry.click();
+
+ await expect(page).toHaveURL(new RegExp(`${record.href}$`));
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toContainText(record.username);
+ await expect(
+ page.locator(`a[href="${record.href}"]`).first(),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toHaveCount(0);
+ await read;
+
+ await page.locator('[data-slot="shared-record-banner-exit"]').click();
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
});
}
-
- test("adult levels and lifecycle", () => {
- const adultJourneyNames = RELEASE_JOURNEYS.filter((journey) =>
- [
- "adult-full-read-scoped-read-write-and-manage",
- "manage-mutation-activity-and-fenced-settings",
- ].includes(journey.name),
- ).map((journey) => journey.name);
-
- expect(adultJourneyNames).toEqual([
- "adult-full-read-scoped-read-write-and-manage",
- "manage-mutation-activity-and-fenced-settings",
- ]);
- });
-
- test("managed profile lifecycle", () => {
- const managedJourneyNames = RELEASE_JOURNEYS.filter((journey) =>
- [
- "managed-profile-creation-delegation-and-revocation",
- "manage-mutation-activity-and-fenced-settings",
- ].includes(journey.name),
- ).map((journey) => journey.name);
-
- expect(managedJourneyNames).toEqual([
- "managed-profile-creation-delegation-and-revocation",
- "manage-mutation-activity-and-fenced-settings",
- ]);
- });
});
From 9bb1e5b2ea731296ae209bdd77e93078fa4ee603 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:14:00 +0200
Subject: [PATCH 23/54] test(02-12): scan shared record accessibility states
- use an isolated shared-record browser session\n- scan empty, loading, error, and refusal states with axe
---
e2e/setup/global-setup.ts | 10 ++
e2e/setup/test-helpers.ts | 1 +
...v137-sharing-managed-profiles-a11y.spec.ts | 92 +++++++++++++++++--
3 files changed, 97 insertions(+), 6 deletions(-)
diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts
index 8fb94174d..24cec4db6 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -138,6 +138,11 @@ export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScopeDelegate.json",
);
+export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve(
+ process.cwd(),
+ "e2e/setup/storageStateScopeA11y.json",
+);
+
async function hashPassword(password: string): Promise {
return hash(password, {
memoryCost: 19456,
@@ -488,6 +493,11 @@ export default async function globalSetup(config: FullConfig): Promise {
E2E_SCOPE_DELEGATE,
SCOPE_DELEGATE_STORAGE_STATE_PATH,
);
+ await captureAuthState(
+ baseURL,
+ E2E_SCOPE_DELEGATE,
+ SCOPE_A11Y_STORAGE_STATE_PATH,
+ );
}
/**
diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts
index 5a72d271f..837e6179d 100644
--- a/e2e/setup/test-helpers.ts
+++ b/e2e/setup/test-helpers.ts
@@ -17,4 +17,5 @@ export {
OWNER_STORAGE_STATE_PATH,
DELEGATE_STORAGE_STATE_PATH,
SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ SCOPE_A11Y_STORAGE_STATE_PATH,
} from "./global-setup";
diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
index ed146f0f3..96812d259 100644
--- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts
+++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
@@ -1,11 +1,91 @@
+import AxeBuilder from "@axe-core/playwright";
+import type { Page } from "@playwright/test";
+
import { expect, test } from "./setup/test";
+import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers";
-import { ACCESSIBILITY_STATES } from "../tests/fixtures/v137/e2e-journeys";
+async function scan(page: Page) {
+ const result = await new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
+ .analyze();
+ expect(result.violations).toEqual([]);
+}
-test.describe("sharing accessibility anchors", () => {
- for (const state of ACCESSIBILITY_STATES) {
- test(`anchor: ${state.name}`, () => {
- expect(state.contract).toBe("inventory-only");
+async function openProfileRecord(page: Page) {
+ await page.goto("/");
+ await page.getByRole("button", { name: "User menu" }).first().click();
+ await page.locator('[data-slot="account-switcher-trigger"]').click();
+ const entry = page
+ .locator('[data-slot="account-switcher-entry"]')
+ .filter({ hasText: "e2e-scope-profile" });
+ await expect(entry).toHaveCount(1);
+ await entry.click();
+ await expect(page).toHaveURL(/\/profile$/);
+}
+
+async function leaveRecord(page: Page) {
+ await page.locator('[data-slot="shared-record-banner-exit"]').click();
+ await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
+ 0,
+ );
+}
+
+test.describe("shared record accessibility states", () => {
+ test.describe.configure({ mode: "serial" });
+ test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH });
+
+ test.beforeEach(({}, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "chromium-desktop",
+ "isolated shared-record accessibility session",
+ );
+ });
+
+ test("success and empty shared profile states have no WCAG violations", async ({
+ page,
+ }) => {
+ await openProfileRecord(page);
+ await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible();
+ await scan(page);
+ await leaveRecord(page);
+ });
+
+ test("the capability hydration loading state has no WCAG violations", async ({
+ page,
+ }) => {
+ await page.route("**/api/auth/me", async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ await route.continue();
});
- }
+ await page.goto("/measurements");
+ await expect(
+ page.locator('[data-slot="record-scope-hydration-gate"]'),
+ ).toBeVisible();
+ await scan(page);
+ });
+
+ test("the shared profile read error state has no WCAG violations", async ({
+ page,
+ }) => {
+ await openProfileRecord(page);
+ await page.route("**/api/profile/summary", (route) =>
+ route.fulfill({ status: 500, body: "{}" }),
+ );
+ await page.reload();
+ await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible();
+ await scan(page);
+ await leaveRecord(page);
+ });
+
+ test("the shared-record refusal state has no WCAG violations", async ({
+ page,
+ }) => {
+ await openProfileRecord(page);
+ await page.goto("/settings/account");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ await scan(page);
+ await page.locator('[data-slot="shared-record-unavailable-leave"]').click();
+ });
});
From b231a7892c1f2b8337d1c6126cec005e6f257c4e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:16:32 +0200
Subject: [PATCH 24/54] test(02-12): retire sharing inventory anchors
- remove obsolete inventory-only journey declarations\n- select persistent shared-record journeys by Playwright project config
---
...v137-sharing-managed-profiles-a11y.spec.ts | 7 ----
e2e/v137-sharing-managed-profiles.spec.ts | 7 ----
playwright.config.ts | 8 ++++
src/__tests__/v137-wave-zero-fixtures.test.ts | 32 --------------
tests/fixtures/v137/e2e-journeys.ts | 42 -------------------
5 files changed, 8 insertions(+), 88 deletions(-)
delete mode 100644 tests/fixtures/v137/e2e-journeys.ts
diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
index 96812d259..0037bc9d5 100644
--- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts
+++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
@@ -34,13 +34,6 @@ test.describe("shared record accessibility states", () => {
test.describe.configure({ mode: "serial" });
test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH });
- test.beforeEach(({}, testInfo) => {
- test.skip(
- testInfo.project.name !== "chromium-desktop",
- "isolated shared-record accessibility session",
- );
- });
-
test("success and empty shared profile states have no WCAG violations", async ({
page,
}) => {
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index db8f38a57..b83e15631 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -29,13 +29,6 @@ test.describe("scoped sharing browser journeys", () => {
test.describe.configure({ mode: "serial" });
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
- test.beforeEach(({}, testInfo) => {
- test.skip(
- testInfo.project.name !== "chromium-desktop",
- "scoped sharing seed has one session and runs once",
- );
- });
-
for (const record of E2E_SCOPE_RECORDS) {
test(`opens only the ${record.domain} record doorway`, async ({ page }) => {
await page.goto("/");
diff --git a/playwright.config.ts b/playwright.config.ts
index 3a79e9e88..bb986a4ca 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -83,6 +83,14 @@ export default defineConfig({
},
{
name: "chromium-mobile",
+ // The scoped-sharing journeys each carry a persistent server-side
+ // session stamp. They use isolated desktop jars and are not responsive
+ // layout checks, so running a second project against the same stamps
+ // would test session interference rather than the record journeys.
+ testIgnore: [
+ "v137-sharing-managed-profiles.spec.ts",
+ "v137-sharing-managed-profiles-a11y.spec.ts",
+ ],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
// `playwright install chromium` instead of also pulling
diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts
index 05d086678..6575f2228 100644
--- a/src/__tests__/v137-wave-zero-fixtures.test.ts
+++ b/src/__tests__/v137-wave-zero-fixtures.test.ts
@@ -1,6 +1,3 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-
import { describe, expect, it } from "vitest";
import {
@@ -13,10 +10,6 @@ import {
NOTIFICATION_CHANNELS,
NOTIFICATION_DELIVERY_MATRIX,
} from "../../tests/fixtures/v137/notification-matrix";
-import {
- ACCESSIBILITY_STATES,
- RELEASE_JOURNEYS,
-} from "../../tests/fixtures/v137/e2e-journeys";
describe("sharing and handler inventories", () => {
it("freezes the exact sharing-domain vocabulary", () => {
@@ -104,28 +97,3 @@ describe("security principals and compatibility", () => {
expect(LEGACY_ACCOUNT_PAYLOADS[3]?.legacyDecoder).toBe("deny");
});
});
-
-describe("journey and accessibility inventory", () => {
- it("names every release journey and accessibility state exactly once", () => {
- expect(RELEASE_JOURNEYS).toHaveLength(8);
- expect(ACCESSIBILITY_STATES).toHaveLength(5);
- expect(new Set(RELEASE_JOURNEYS.map(({ name }) => name)).size).toBe(
- RELEASE_JOURNEYS.length,
- );
- expect(new Set(ACCESSIBILITY_STATES.map(({ name }) => name)).size).toBe(
- ACCESSIBILITY_STATES.length,
- );
- });
-
- it("leaves the browser anchors discoverable without focused or skipped cases", () => {
- const anchorFiles = [
- "v137-sharing-managed-profiles.spec.ts",
- "v137-sharing-managed-profiles-a11y.spec.ts",
- ];
-
- for (const file of anchorFiles) {
- const source = readFileSync(join(process.cwd(), "e2e", file), "utf8");
- expect(source).not.toMatch(/\b(?:test|describe)\.(?:only|skip)\b/);
- }
- });
-});
diff --git a/tests/fixtures/v137/e2e-journeys.ts b/tests/fixtures/v137/e2e-journeys.ts
deleted file mode 100644
index 1cc016116..000000000
--- a/tests/fixtures/v137/e2e-journeys.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-export const RELEASE_JOURNEYS = [
- {
- name: "adult-full-read-scoped-read-write-and-manage",
- contract: "inventory-only",
- },
- {
- name: "manage-mutation-activity-and-fenced-settings",
- contract: "inventory-only",
- },
- {
- name: "managed-profile-two-guardian-lifecycle",
- contract: "inventory-only",
- },
- {
- name: "two-guardian-two-record-notification-delivery",
- contract: "inventory-only",
- },
- {
- name: "revoked-manage-idempotency-replay",
- contract: "inventory-only",
- },
- {
- name: "delegate-provider-no-egress-owner-positive",
- contract: "inventory-only",
- },
- {
- name: "durable-import-actor-attribution",
- contract: "inventory-only",
- },
- {
- name: "legacy-client-compatibility",
- contract: "inventory-only",
- },
-] as const;
-
-export const ACCESSIBILITY_STATES = [
- { name: "success", contract: "inventory-only" },
- { name: "empty", contract: "inventory-only" },
- { name: "loading", contract: "inventory-only" },
- { name: "error", contract: "inventory-only" },
- { name: "refusal", contract: "inventory-only" },
-] as const;
From 4999c786e30b5d9b19f8e8ceca0fc0f3b90f3425 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:26:21 +0200
Subject: [PATCH 25/54] test(02-12): exercise access-level record journeys
- Seed adult and managed whole-record grants for browser coverage\n- Verify scoped writes, settings boundaries, and refused coach reads
---
e2e/setup/global-setup.ts | 80 ++++++++
e2e/setup/test-helpers.ts | 1 +
e2e/v137-sharing-managed-profiles.spec.ts | 234 +++++++++++++++++++++-
3 files changed, 311 insertions(+), 4 deletions(-)
diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts
index 24cec4db6..697b373fb 100644
--- a/e2e/setup/global-setup.ts
+++ b/e2e/setup/global-setup.ts
@@ -93,6 +93,33 @@ export const E2E_SCOPE_RECORDS = [
{ domain: "documents", username: "e2e-scope-documents", href: "/documents" },
] as const;
+/**
+ * Whole-record grants used to prove the three adult access levels and the
+ * separate managed-profile boundary in a real browser session.
+ */
+export const E2E_LEVEL_RECORDS = [
+ {
+ username: "e2e-level-read",
+ access: "READ",
+ recordKind: "shared",
+ },
+ {
+ username: "e2e-level-write",
+ access: "WRITE",
+ recordKind: "shared",
+ },
+ {
+ username: "e2e-level-manage",
+ access: "MANAGE",
+ recordKind: "shared",
+ },
+ {
+ username: "e2e-level-managed",
+ access: "MANAGE",
+ recordKind: "managed",
+ },
+] as const;
+
/**
* Weights seeded one per account, chosen so neither can be mistaken for the
* other in rendered markup and neither collides with a value any other spec
@@ -331,6 +358,59 @@ export default async function globalSetup(config: FullConfig): Promise {
);
}
+ // Whole-record fixtures intentionally sit beside the narrow records
+ // above. They make the browser prove the presentation and mutation
+ // boundaries of READ, WRITE, MANAGE, and a guardian-managed record
+ // without changing the invitation lifecycle fixture below.
+ for (const record of E2E_LEVEL_RECORDS) {
+ await pool.query(
+ `INSERT INTO users
+ (id, username, email, password_hash, role, created_at, updated_at,
+ managed_profile_at, onboarding_completed_at,
+ onboarding_tour_completed, module_preferences_json)
+ VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb)
+ ON CONFLICT (username) DO UPDATE SET
+ email = EXCLUDED.email,
+ password_hash = NULL,
+ updated_at = EXCLUDED.updated_at,
+ managed_profile_at = EXCLUDED.managed_profile_at,
+ onboarding_completed_at = EXCLUDED.onboarding_completed_at,
+ onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
+ module_preferences_json = EXCLUDED.module_preferences_json`,
+ [
+ cuid(),
+ record.username,
+ `${record.username}@healthlog.test`,
+ now,
+ record.recordKind === "managed" ? now : null,
+ JSON.stringify({
+ medications: true,
+ labs: true,
+ illness: true,
+ mood: true,
+ mentalHealth: true,
+ inboundDocuments: true,
+ }),
+ ],
+ );
+
+ await pool.query(
+ `INSERT INTO account_grants
+ (id, grantor_id, grantee_id, access, scope_json, invited_at,
+ accepted_at, created_at)
+ SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3
+ FROM users owner, users delegate
+ WHERE owner.username = $4 AND delegate.username = $5`,
+ [
+ cuid(),
+ record.access,
+ now,
+ record.username,
+ E2E_SCOPE_DELEGATE.username,
+ ],
+ );
+ }
+
// Cycle is delegated to its profile gate rather than module preferences.
await pool.query(
`INSERT INTO cycle_profiles
diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts
index 837e6179d..0b97ecc4e 100644
--- a/e2e/setup/test-helpers.ts
+++ b/e2e/setup/test-helpers.ts
@@ -11,6 +11,7 @@ export {
E2E_OWNER,
E2E_SCOPE_DELEGATE,
E2E_SCOPE_RECORDS,
+ E2E_LEVEL_RECORDS,
E2E_DELEGATE_MARKER_KG,
E2E_OWNER_MARKER_KG,
STORAGE_STATE_PATH,
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index b83e15631..5239de994 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
+ E2E_LEVEL_RECORDS,
E2E_SCOPE_RECORDS,
SCOPE_DELEGATE_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
@@ -25,6 +26,31 @@ async function openSwitcher(page: Page) {
).toBeVisible();
}
+async function openRecord(page: Page, username: string) {
+ await page.goto("/");
+ await openSwitcher(page);
+
+ const entry = page
+ .locator('[data-slot="account-switcher-entry"]')
+ .filter({ hasText: username });
+ await expect(entry).toHaveCount(1);
+ const accountId = await entry.getAttribute("data-account-id");
+ if (accountId === null) {
+ throw new Error(`Account switcher entry for ${username} has no account id`);
+ }
+ const accessLevel = await entry.getAttribute("data-access-level");
+ const recordKind = await entry.getAttribute("data-record-kind");
+ await entry.click();
+ return { accountId, accessLevel, recordKind };
+}
+
+async function leaveRecord(page: Page) {
+ await page.locator('[data-slot="shared-record-banner-exit"]').click();
+ await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
+ 0,
+ );
+}
+
test.describe("scoped sharing browser journeys", () => {
test.describe.configure({ mode: "serial" });
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
@@ -58,10 +84,210 @@ test.describe("scoped sharing browser journeys", () => {
).toHaveCount(0);
await read;
- await page.locator('[data-slot="shared-record-banner-exit"]').click();
- await expect(
- page.locator('[data-slot="shared-record-banner"]'),
- ).toHaveCount(0);
+ await leaveRecord(page);
});
}
+
+ test("keeps an adult READ grant read-only and outside Settings", async ({
+ page,
+ }) => {
+ const record = E2E_LEVEL_RECORDS.find(
+ (candidate) => candidate.access === "READ",
+ );
+ if (!record) throw new Error("READ fixture is missing");
+
+ const { accessLevel, recordKind } = await openRecord(page, record.username);
+ expect(accessLevel).toBe("view");
+ expect(recordKind).toBe("shared");
+
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toHaveAttribute("data-access-level", "view");
+ await expect(banner).toHaveAttribute("data-record-kind", "shared");
+ await expect(banner).toContainText(record.username);
+
+ await page.goto("/measurements");
+ await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0);
+
+ await page.goto("/settings/account");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ await leaveRecord(page);
+ });
+
+ test("lets an adult WRITE grant add only to the selected record", async ({
+ page,
+ }) => {
+ const record = E2E_LEVEL_RECORDS.find(
+ (candidate) => candidate.access === "WRITE",
+ );
+ if (!record) throw new Error("WRITE fixture is missing");
+
+ const { accountId, accessLevel, recordKind } = await openRecord(
+ page,
+ record.username,
+ );
+ expect(accessLevel).toBe("view-and-add");
+ expect(recordKind).toBe("shared");
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toHaveAttribute("data-account-id", accountId);
+ await expect(banner).toHaveAttribute("data-access-level", "view-and-add");
+ await expect(banner).toHaveAttribute("data-record-kind", "shared");
+
+ await page.goto("/measurements");
+ await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
+ const post = page.waitForResponse(
+ (response) =>
+ response.request().method() === "POST" &&
+ new URL(response.url()).pathname === "/api/measurements",
+ );
+ await page.locator('[data-slot="measurement-add"]').click();
+ await page.locator("#sys").fill("124");
+ await page.locator("#dia").fill("78");
+ await page.getByRole("button", { name: /^save$/i }).click();
+ expect((await post).status()).toBeLessThan(300);
+
+ const read = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname === "/api/measurements",
+ );
+ await page.reload();
+ const payload = await read;
+ expect(JSON.stringify(await payload.json())).toContain("124");
+ await expect(banner).toHaveAttribute("data-account-id", accountId);
+
+ await page.goto("/settings/account");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ await leaveRecord(page);
+ });
+
+ test("opens MANAGE-only generated reads without opening adult Settings", async ({
+ page,
+ }) => {
+ const record = E2E_LEVEL_RECORDS.find(
+ (candidate) =>
+ candidate.access === "MANAGE" && candidate.recordKind === "shared",
+ );
+ if (!record) throw new Error("adult MANAGE fixture is missing");
+
+ const { accessLevel, recordKind } = await openRecord(page, record.username);
+ expect(accessLevel).toBe("manage");
+ expect(recordKind).toBe("shared");
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toHaveAttribute("data-access-level", "manage");
+ await expect(banner).toHaveAttribute("data-record-kind", "shared");
+
+ const generatedRead = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname === "/api/dashboard/summary",
+ );
+ await page.goto("/");
+ expect((await generatedRead).status()).toBeLessThan(300);
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toHaveCount(0);
+
+ await page.goto("/settings/account");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ await leaveRecord(page);
+ });
+
+ test("keeps managed Settings on the active record and integrations status-only", async ({
+ page,
+ }) => {
+ const record = E2E_LEVEL_RECORDS.find(
+ (candidate) => candidate.recordKind === "managed",
+ );
+ if (!record) throw new Error("managed record fixture is missing");
+
+ const { accountId, accessLevel, recordKind } = await openRecord(
+ page,
+ record.username,
+ );
+ expect(accessLevel).toBe("manage");
+ expect(recordKind).toBe("managed");
+ const banner = page.locator('[data-slot="shared-record-banner"]');
+ await expect(banner).toHaveAttribute("data-account-id", accountId);
+ await expect(banner).toHaveAttribute("data-access-level", "manage");
+ await expect(banner).toHaveAttribute("data-record-kind", "managed");
+
+ await page.goto("/settings/account");
+ const profile = page.locator('[data-record-settings-family="profile"]');
+ await expect(profile).toBeVisible();
+ await expect(profile).toHaveAttribute("data-record-id", accountId);
+
+ const integrationRead = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname ===
+ "/api/record-settings/integrations",
+ );
+ await page.goto("/settings/integrations");
+ const integrationResponse = await integrationRead;
+ expect(integrationResponse.status()).toBeLessThan(300);
+ const integrationPayload = (await integrationResponse.json()) as {
+ data?: { recordId?: string };
+ };
+ expect(integrationPayload.data?.recordId).toBe(accountId);
+ const integrations = page.locator(
+ '[data-record-settings-family="integrations"]',
+ );
+ await expect(integrations).toBeVisible();
+ await expect(integrations).toHaveAttribute("data-record-id", accountId);
+ await expect(
+ integrations.locator("button, input, select, textarea"),
+ ).toHaveCount(0);
+
+ await page.goBack();
+ await expect(profile).toHaveAttribute("data-record-id", accountId);
+ await page.goForward();
+ await expect(integrations).toHaveAttribute("data-record-id", accountId);
+ await expect(banner).toHaveAttribute("data-account-id", accountId);
+
+ await leaveRecord(page);
+ await page.goto("/measurements");
+ await expect(banner).toHaveCount(0);
+ });
+
+ test("refuses direct ungranted routes before coach reads start", async ({
+ page,
+ }) => {
+ await openRecord(page, "e2e-scope-measurements");
+ const protectedCoachReads: string[] = [];
+ const trackCoachRead = (request: { url(): string }) => {
+ const path = new URL(request.url()).pathname;
+ if (
+ [
+ "/api/insights/coach/nudge-status",
+ "/api/coach/about-me/questions",
+ "/api/auth/me/notification-prefs",
+ ].includes(path)
+ ) {
+ protectedCoachReads.push(path);
+ }
+ };
+ page.on("request", trackCoachRead);
+ try {
+ await page.goto("/coach");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ expect(protectedCoachReads).toEqual([]);
+
+ await page.goto("/settings/access");
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable"]'),
+ ).toBeVisible();
+ expect(protectedCoachReads).toEqual([]);
+ } finally {
+ page.off("request", trackCoachRead);
+ }
+ await leaveRecord(page);
+ });
});
From 123bc15f16806f97fbcd367260dabf8b3787ac2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:34:16 +0200
Subject: [PATCH 26/54] fix(02-12): restore shared banner contrast
- Use the foreground token for warning context copy\n- Serialize shared-session browser journeys
---
e2e/v137-sharing-managed-profiles.spec.ts | 3 +--
.../layout/__tests__/shared-record-chrome.test.tsx | 13 +++++++++++++
src/components/layout/shared-record-banner.tsx | 13 ++++++++-----
3 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 5239de994..95eb1a1b7 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -51,8 +51,7 @@ async function leaveRecord(page: Page) {
);
}
-test.describe("scoped sharing browser journeys", () => {
- test.describe.configure({ mode: "serial" });
+test.describe.serial("scoped sharing browser journeys", () => {
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
for (const record of E2E_SCOPE_RECORDS) {
diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx
index b2fbd65ca..53a2049f2 100644
--- a/src/components/layout/__tests__/shared-record-chrome.test.tsx
+++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx
@@ -169,6 +169,19 @@ describe("", () => {
expect(html).toContain("bg-warning/15");
expect(html).toContain("border-warning/40");
});
+
+ it("keeps the warning-banner context copy at the foreground contrast floor", () => {
+ const html = render(
+ { accounts: [OWNER], active: OWNER, canSwitch: true },
+ ,
+ );
+ const context = html.match(
+ /]*data-slot="shared-record-banner-context"[^>]*>/,
+ )?.[0];
+
+ expect(context).toContain("text-foreground");
+ expect(context).not.toContain("text-muted-foreground");
+ });
});
describe("", () => {
diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx
index 3d8744abb..9d30ce719 100644
--- a/src/components/layout/shared-record-banner.tsx
+++ b/src/components/layout/shared-record-banner.tsx
@@ -15,7 +15,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* are switched will log their own blood pressure into somebody else's record,
* and there is no undo for a reading that was never theirs. So the banner
* states three things in one line — that this is not your record, whose it is,
- * and that you can only read it — and carries the way out beside them.
+ * and what sharing level applies — and carries the way out beside them.
*
* ## Why it looks like this
*
@@ -29,9 +29,9 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* somebody stops seeing after the third switch, and the failure mode here is
* a person who has stopped noticing.
*
- * The name is `text-foreground` and the qualifier `text-muted-foreground`, per
- * the two-tier text rule — the person's name is content, the read-only note is
- * meta.
+ * The name and qualifier both use `text-foreground`: the warning wash is too
+ * dark for the muted token at this text size, while the foreground token keeps
+ * the context legible at the same contrast floor as the name.
*
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
@@ -79,7 +79,10 @@ export function SharedRecordBanner() {
{t("recordSharing.banner.viewing", { name })}
{" "}
-
+
{recordKindLabel(presentation.recordKind, t)}.{" "}
{accessLabel(presentation.access, t)}
From 8988cd9e419f9b94f5eb217e12a1318336e76411 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 05:34:16 +0200
Subject: [PATCH 27/54] chore(02-12): ignore generated Playwright session
states
- Cover all e2e storage-state cookie files\n- Keep generated browser sessions out of version control
---
.gitignore | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/.gitignore b/.gitignore
index e5c925bfc..e121cf65a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,9 +18,7 @@
/test-results
/playwright-report
/playwright/.cache
-/e2e/setup/storageState.json
-/e2e/setup/storageStateOwner.json
-/e2e/setup/storageStateDelegate.json
+/e2e/setup/storageState*.json
# next.js
/.next/
@@ -95,4 +93,4 @@ next-env.d.ts
# times over. The same two names are excluded from eslint and vitest, for the
# same reason in a different tool.
/.wt-*/
-/.worktrees/
\ No newline at end of file
+/.worktrees/
From c21957db9f76f61d2a451deecf0a218a431c8505 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 08:19:43 +0200
Subject: [PATCH 28/54] fix(02-12): keep record switcher entries reachable
- Constrain the shared-record submenu to available viewport height\n- Scroll long grant lists without hiding delegated records
---
src/components/layout/account-switcher-menu.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx
index 8b7df237a..66e39320c 100644
--- a/src/components/layout/account-switcher-menu.tsx
+++ b/src/components/layout/account-switcher-menu.tsx
@@ -66,7 +66,7 @@ export function AccountSwitcherMenuItems() {
Date: Thu, 6 Aug 2026 08:25:42 +0200
Subject: [PATCH 29/54] test(02-12): make record switcher journeys
deterministic
- Address granted records by their published username\n- Start the profile error mock before record navigation
---
e2e/v137-sharing-managed-profiles-a11y.spec.ts | 9 ++++-----
e2e/v137-sharing-managed-profiles.spec.ts | 12 ++++++------
src/components/layout/account-switcher-menu.tsx | 1 +
3 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
index 0037bc9d5..603948b2e 100644
--- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts
+++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
@@ -15,9 +15,9 @@ async function openProfileRecord(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
- const entry = page
- .locator('[data-slot="account-switcher-entry"]')
- .filter({ hasText: "e2e-scope-profile" });
+ const entry = page.locator(
+ '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-profile"]',
+ );
await expect(entry).toHaveCount(1);
await entry.click();
await expect(page).toHaveURL(/\/profile$/);
@@ -60,11 +60,10 @@ test.describe("shared record accessibility states", () => {
test("the shared profile read error state has no WCAG violations", async ({
page,
}) => {
- await openProfileRecord(page);
await page.route("**/api/profile/summary", (route) =>
route.fulfill({ status: 500, body: "{}" }),
);
- await page.reload();
+ await openProfileRecord(page);
await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible();
await scan(page);
await leaveRecord(page);
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 95eb1a1b7..3664558a3 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -30,9 +30,9 @@ async function openRecord(page: Page, username: string) {
await page.goto("/");
await openSwitcher(page);
- const entry = page
- .locator('[data-slot="account-switcher-entry"]')
- .filter({ hasText: username });
+ const entry = page.locator(
+ `[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
+ );
await expect(entry).toHaveCount(1);
const accountId = await entry.getAttribute("data-account-id");
if (accountId === null) {
@@ -59,9 +59,9 @@ test.describe.serial("scoped sharing browser journeys", () => {
await page.goto("/");
await openSwitcher(page);
- const entry = page
- .locator('[data-slot="account-switcher-entry"]')
- .filter({ hasText: record.username });
+ const entry = page.locator(
+ `[data-slot="account-switcher-entry"][data-account-username="${record.username}"]`,
+ );
await expect(entry).toHaveCount(1);
const read = page.waitForResponse(
diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx
index 66e39320c..8fac8a3b6 100644
--- a/src/components/layout/account-switcher-menu.tsx
+++ b/src/components/layout/account-switcher-menu.tsx
@@ -89,6 +89,7 @@ export function AccountSwitcherMenuItems() {
key={account.accountId}
data-slot="account-switcher-entry"
data-account-id={account.accountId}
+ data-account-username={account.username}
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
className="cursor-pointer"
From 358bb3946513b06301bb427d2a02d154461e66d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 08:34:02 +0200
Subject: [PATCH 30/54] fix(02-12): keep scoped record polls dormant
- suppress whole-record achievements reads for narrowed grants\n- stabilize record journey response assertions
---
...v137-sharing-managed-profiles-a11y.spec.ts | 2 +-
e2e/v137-sharing-managed-profiles.spec.ts | 24 +++++++++----------
.../__tests__/use-achievements-query.test.tsx | 12 +++++++++-
src/lib/queries/use-achievements-query.ts | 17 +++++++++++--
4 files changed, 38 insertions(+), 17 deletions(-)
diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
index 603948b2e..6396eba29 100644
--- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts
+++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts
@@ -60,7 +60,7 @@ test.describe("shared record accessibility states", () => {
test("the shared profile read error state has no WCAG violations", async ({
page,
}) => {
- await page.route("**/api/profile/summary", (route) =>
+ await page.route("**/api/profile/summary**", (route) =>
route.fulfill({ status: 500, body: "{}" }),
);
await openProfileRecord(page);
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 3664558a3..8066eddf5 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -146,14 +146,13 @@ test.describe.serial("scoped sharing browser journeys", () => {
await page.getByRole("button", { name: /^save$/i }).click();
expect((await post).status()).toBeLessThan(300);
- const read = page.waitForResponse(
- (response) =>
- response.request().method() === "GET" &&
- new URL(response.url()).pathname === "/api/measurements",
- );
await page.reload();
- const payload = await read;
- expect(JSON.stringify(await payload.json())).toContain("124");
+ const payload = await page.evaluate(async () => {
+ const response = await fetch("/api/measurements");
+ return { status: response.status, body: await response.json() };
+ });
+ expect(payload.status).toBeLessThan(300);
+ expect(JSON.stringify(payload.body)).toContain("124");
await expect(banner).toHaveAttribute("data-account-id", accountId);
await page.goto("/settings/account");
@@ -179,13 +178,12 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveAttribute("data-access-level", "manage");
await expect(banner).toHaveAttribute("data-record-kind", "shared");
- const generatedRead = page.waitForResponse(
- (response) =>
- response.request().method() === "GET" &&
- new URL(response.url()).pathname === "/api/dashboard/summary",
- );
await page.goto("/");
- expect((await generatedRead).status()).toBeLessThan(300);
+ const generatedRead = await page.evaluate(async () => {
+ const response = await fetch("/api/dashboard/summary");
+ return response.status;
+ });
+ expect(generatedRead).toBeLessThan(300);
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toHaveCount(0);
diff --git a/src/lib/queries/__tests__/use-achievements-query.test.tsx b/src/lib/queries/__tests__/use-achievements-query.test.tsx
index edb6daf14..b8b51e495 100644
--- a/src/lib/queries/__tests__/use-achievements-query.test.tsx
+++ b/src/lib/queries/__tests__/use-achievements-query.test.tsx
@@ -26,7 +26,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { I18nProvider } from "@/lib/i18n/context";
-import { useAchievementsQuery } from "../use-achievements-query";
+import {
+ canFetchAchievements,
+ useAchievementsQuery,
+} from "../use-achievements-query";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
@@ -90,6 +93,13 @@ function withQueryClient(node: ReactNode): string {
}
describe("useAchievementsQuery — consumer collapse", () => {
+ it("does not start the whole-record poll in a narrowed shared record", () => {
+ expect(canFetchAchievements({ sections: ["profile"] })).toBe(false);
+ expect(canFetchAchievements({ sections: [] })).toBe(false);
+ expect(canFetchAchievements({ sections: null })).toBe(true);
+ expect(canFetchAchievements(null)).toBe(true);
+ });
+
it("fires a single network call when three consumers mount together", async () => {
withQueryClient(
<>
diff --git a/src/lib/queries/use-achievements-query.ts b/src/lib/queries/use-achievements-query.ts
index 88f7f7574..2dc632a9e 100644
--- a/src/lib/queries/use-achievements-query.ts
+++ b/src/lib/queries/use-achievements-query.ts
@@ -5,6 +5,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
+import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
import type {
AchievementMetrics,
AchievementProgress,
@@ -70,6 +71,17 @@ function fetchAchievements(): Promise {
return apiGet("/api/gamification/achievements");
}
+/**
+ * Achievements aggregate more than one record section, so a narrowed grant
+ * never reaches the endpoint. Keep its shell-level poll dormant instead of
+ * turning a correctly refused background read into an apparent grant loss.
+ */
+export function canFetchAchievements(
+ active: Pick | null | undefined,
+): boolean {
+ return active === null || active === undefined || active.sections === null;
+}
+
/**
* Shared hook. Returns the unwrapped `data` payload typed as
* `AchievementsPayload`. Consumers narrow at the call site.
@@ -77,9 +89,10 @@ function fetchAchievements(): Promise {
export function useAchievementsQuery(
options: UseAchievementsQueryOptions = {},
): UseQueryResult {
- const { isAuthenticated } = useAuth();
+ const { isAuthenticated, user } = useAuth();
const enabled =
- options.enabled !== undefined ? options.enabled : isAuthenticated;
+ (options.enabled !== undefined ? options.enabled : isAuthenticated) &&
+ canFetchAchievements(user?.accountAccess?.active);
const refetchInterval = options.refetchInterval ?? false;
return useQuery({
From b844d04676223e08a2529290dda745b49fee0663 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 13:39:37 +0200
Subject: [PATCH 31/54] fix(02-12): reject divergent shared-record state
- Verify the active record matches its canonical switcher entry\n- Skip the owner OCR probe in delegated Labs records
---
e2e/v137-sharing-managed-profiles.spec.ts | 12 +++++++
src/app/labs/page.tsx | 15 ++++++--
.../__tests__/ocr-capability-gate.test.ts | 29 ++++++++++++++++
src/components/labs/use-ocr-extract.ts | 17 ++++++++++
.../__tests__/account-access-schema.test.ts | 25 ++++++++++++++
src/lib/sharing/account-access-schema.ts | 34 ++++++++++++++++++-
6 files changed, 129 insertions(+), 3 deletions(-)
create mode 100644 src/components/labs/__tests__/ocr-capability-gate.test.ts
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 8066eddf5..016bb3f76 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -64,6 +64,15 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(entry).toHaveCount(1);
+ const unexpectedOcrCapability =
+ record.domain === "labs"
+ ? page.waitForRequest(
+ (request) =>
+ request.method() === "GET" &&
+ new URL(request.url()).pathname === "/api/labs/ocr/capability",
+ { timeout: 500 },
+ )
+ : null;
const read = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
@@ -82,6 +91,9 @@ test.describe.serial("scoped sharing browser journeys", () => {
page.locator('[data-slot="shared-record-unavailable"]'),
).toHaveCount(0);
await read;
+ if (unexpectedOcrCapability) {
+ await expect(unexpectedOcrCapability).rejects.toThrow(/Timeout/);
+ }
await leaveRecord(page);
});
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 381bb1723..238e26f80 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -17,7 +17,10 @@ import {
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
-import { useOcrCapability } from "@/components/labs/use-ocr-extract";
+import {
+ shouldProbeOcrCapability,
+ useOcrCapability,
+} from "@/components/labs/use-ocr-extract";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -53,7 +56,6 @@ export default function LabsPage() {
// v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows
// when the user's configured AI provider can read images (capability probe).
const [scanOpen, setScanOpen] = useState(false);
- const ocrCapability = useOcrCapability(isAuthenticated);
// Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
@@ -61,6 +63,15 @@ export default function LabsPage() {
// explicit `false`. Every `/api/labs/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = inSharedRecord || user?.modules?.labs !== false;
+ const ocrCapability = useOcrCapability(
+ shouldProbeOcrCapability({
+ isAuthenticated,
+ isLoading,
+ labsEnabled: enabled,
+ mounted,
+ canManage,
+ }),
+ );
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts
new file mode 100644
index 000000000..4e3be2b55
--- /dev/null
+++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { shouldProbeOcrCapability } from "../use-ocr-extract";
+
+describe("shouldProbeOcrCapability", () => {
+ it("runs the owner-only capability probe only when the scan control can render", () => {
+ const ownerControl = {
+ isAuthenticated: true,
+ isLoading: false,
+ labsEnabled: true,
+ mounted: true,
+ canManage: true,
+ };
+
+ expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
+ expect(
+ shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }),
+ ).toBe(false);
+ expect(
+ shouldProbeOcrCapability({ ...ownerControl, canManage: false }),
+ ).toBe(false);
+ expect(
+ shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }),
+ ).toBe(false);
+ expect(shouldProbeOcrCapability({ ...ownerControl, mounted: false })).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts
index 8f05ebcb1..7f2bd30c7 100644
--- a/src/components/labs/use-ocr-extract.ts
+++ b/src/components/labs/use-ocr-extract.ts
@@ -40,6 +40,23 @@ export interface OcrCommitResult extends OcrCommitResponseDto {
inserted: LabResultDto[];
}
+/** The capability route resolves the caller's own provider configuration. */
+export function shouldProbeOcrCapability({
+ isAuthenticated,
+ isLoading,
+ labsEnabled,
+ mounted,
+ canManage,
+}: {
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ labsEnabled: boolean;
+ mounted: boolean;
+ canManage: boolean;
+}): boolean {
+ return isAuthenticated && !isLoading && labsEnabled && mounted && canManage;
+}
+
/** Capability probe — refetched when the scan dialog opens. */
export function useOcrCapability(enabled: boolean) {
return useQuery({
diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts
index b508839e7..69db679b9 100644
--- a/src/lib/sharing/__tests__/account-access-schema.test.ts
+++ b/src/lib/sharing/__tests__/account-access-schema.test.ts
@@ -58,6 +58,31 @@ describe("parseAccountAccess", () => {
});
});
+ it.each([
+ [
+ "legacy access and resolved level",
+ { access: "write", level: "write", canWrite: true },
+ {},
+ ],
+ ["record kind", { recordKind: "managed" }, { recordKind: "managed" }],
+ ["scope", { sections: ["measurements"] }, {}],
+ [
+ "resolved write capability",
+ { access: "write", level: "write", canWrite: true },
+ {},
+ ],
+ ["username", { username: "different-record" }, {}],
+ ["display name", { displayName: "Different record" }, {}],
+ ])(
+ "fails closed when active disagrees with its canonical entry on %s",
+ (_field, activeOverrides, blockOverrides) => {
+ const active = { ...sharedEntry, ...activeOverrides };
+ const block = accessBlock({ active, ...blockOverrides });
+
+ expect(parseAccountAccess(block)).toBeNull();
+ },
+ );
+
it.each([
["unknown level", { level: "owner" }],
["unknown record kind", { recordKind: "other" }],
diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts
index 2d87bc1f0..72faf24e2 100644
--- a/src/lib/sharing/account-access-schema.ts
+++ b/src/lib/sharing/account-access-schema.ts
@@ -61,6 +61,33 @@ export const accountAccessBlockSchema = z.object({
canSwitch: z.boolean(),
});
+/** Both published views of the active record must name the same resolved grant. */
+function matchesCanonicalAccountAccessEntry(
+ active: z.infer,
+ canonical: z.infer,
+): boolean {
+ const activeSections = active.sections;
+ const canonicalSections = canonical.sections;
+ const sameSections =
+ activeSections === null || canonicalSections === null
+ ? activeSections === canonicalSections
+ : activeSections.length === canonicalSections.length &&
+ activeSections.every(
+ (section, index) => section === canonicalSections[index],
+ );
+
+ return (
+ active.accountId === canonical.accountId &&
+ active.username === canonical.username &&
+ active.displayName === canonical.displayName &&
+ active.access === canonical.access &&
+ active.level === canonical.level &&
+ active.recordKind === canonical.recordKind &&
+ active.canWrite === canonical.canWrite &&
+ sameSections
+ );
+}
+
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const parsed = accountAccessBlockSchema.safeParse(value);
@@ -88,7 +115,12 @@ export function parseAccountAccess(value: unknown): AccountAccess | null {
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
- if (!canonicalActive) return null;
+ if (
+ !canonicalActive ||
+ !matchesCanonicalAccountAccessEntry(active, canonicalActive)
+ ) {
+ return null;
+ }
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
From 21e111c06c8c7190e3094c64cd3d77539727f266 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 14:03:13 +0200
Subject: [PATCH 32/54] fix(02-12): refuse malformed record access
- Preserve absent-server compatibility while rejecting published invalid blocks\n- Partition refused record reads and withhold protected presentation
---
e2e/v137-sharing-managed-profiles.spec.ts | 109 ++++++++++++++++++
.../__tests__/page-launch-params.test.tsx | 6 +
src/app/coach/page-client.tsx | 12 +-
.../auth-shell-shared-record-doors.test.tsx | 23 +++-
src/components/layout/auth-shell.tsx | 20 +++-
.../use-auth-account-access-state.test.ts | 106 +++++++++++++++++
.../__tests__/use-record-capabilities.test.ts | 13 +++
src/hooks/use-auth.ts | 53 +++++++--
src/hooks/use-record-capabilities.ts | 20 +++-
.../query-keys/__tests__/record-scope.test.ts | 19 +++
src/lib/query-keys/record-scope.ts | 17 +++
11 files changed, 380 insertions(+), 18 deletions(-)
create mode 100644 src/hooks/__tests__/use-auth-account-access-state.test.ts
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 016bb3f76..2a2a503d2 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -51,6 +51,45 @@ async function leaveRecord(page: Page) {
);
}
+function withDivergentActiveAccountAccess(body: unknown): unknown {
+ if (body === null || typeof body !== "object") {
+ throw new Error("Expected an object from /api/auth/me");
+ }
+
+ const envelope = body as { data?: unknown };
+ const payload = "data" in envelope ? envelope.data : envelope;
+ if (payload === null || typeof payload !== "object") {
+ throw new Error("Expected an auth payload from /api/auth/me");
+ }
+
+ const auth = payload as {
+ accountAccess?: {
+ active?: {
+ access?: unknown;
+ level?: unknown;
+ canWrite?: unknown;
+ } | null;
+ };
+ };
+ if (!auth.accountAccess?.active) {
+ throw new Error("Expected a switched account-access payload");
+ }
+
+ const corrupted = {
+ ...auth,
+ accountAccess: {
+ ...auth.accountAccess,
+ active: {
+ ...auth.accountAccess.active,
+ access: "write",
+ level: "write",
+ canWrite: true,
+ },
+ },
+ };
+ return "data" in envelope ? { ...envelope, data: corrupted } : corrupted;
+}
+
test.describe.serial("scoped sharing browser journeys", () => {
test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH });
@@ -99,6 +138,76 @@ test.describe.serial("scoped sharing browser journeys", () => {
});
}
+ test("refuses a malformed switched payload before owner controls or reads mount", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await openSwitcher(page);
+
+ const entry = page.locator(
+ '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
+ );
+ await expect(entry).toHaveCount(1);
+
+ let corruptNextAuthPayload = true;
+ await page.route("**/api/auth/me", async (route) => {
+ const response = await route.fetch();
+ if (!corruptNextAuthPayload) {
+ await route.fulfill({ response });
+ return;
+ }
+ corruptNextAuthPayload = false;
+ await route.fulfill({
+ response,
+ json: withDivergentActiveAccountAccess(await response.json()),
+ });
+ });
+
+ const ownerOnlyReads: string[] = [];
+ const trackOwnerOnlyRead = (request: {
+ method(): string;
+ url(): string;
+ }) => {
+ if (request.method() !== "GET") return;
+ const path = new URL(request.url()).pathname;
+ if (
+ [
+ "/api/labs/ocr/capability",
+ "/api/insights/coach/nudge-status",
+ "/api/coach/about-me/questions",
+ "/api/auth/me/notification-prefs",
+ ].includes(path)
+ ) {
+ ownerOnlyReads.push(path);
+ }
+ };
+ page.on("request", trackOwnerOnlyRead);
+ try {
+ await entry.click();
+
+ await expect(page).toHaveURL(/\/labs$/);
+ await expect(
+ page.locator('[data-slot="invalid-record-access-refusal"]'),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-slot="shared-record-unavailable-leave"]'),
+ ).toBeVisible();
+ await expect(page.locator('[data-tour-id="labs-hero"]')).toHaveCount(0);
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
+ expect(ownerOnlyReads).toEqual([]);
+
+ await page
+ .locator('[data-slot="shared-record-unavailable-leave"]')
+ .click();
+ await expect(page).toHaveURL(/\/$/);
+ } finally {
+ page.off("request", trackOwnerOnlyRead);
+ await page.unroute("**/api/auth/me");
+ }
+ });
+
test("keeps an adult READ grant read-only and outside Settings", async ({
page,
}) => {
diff --git a/src/app/coach/__tests__/page-launch-params.test.tsx b/src/app/coach/__tests__/page-launch-params.test.tsx
index 13c42e388..3f56c28c8 100644
--- a/src/app/coach/__tests__/page-launch-params.test.tsx
+++ b/src/app/coach/__tests__/page-launch-params.test.tsx
@@ -44,6 +44,12 @@ vi.mock("@/hooks/use-feature-flags", () => ({
vi.mock("@/hooks/use-disable-coach", () => ({
useDisableCoach: () => false,
}));
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: {}, isLoading: false }),
+}));
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({ inSharedRecord: false }),
+}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
}));
diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx
index db99c0c42..0b8bb28ce 100644
--- a/src/app/coach/page-client.tsx
+++ b/src/app/coach/page-client.tsx
@@ -10,6 +10,8 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
+import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
@@ -163,6 +165,8 @@ export default function CoachPageClient() {
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
+ const { user, isLoading } = useAuth();
+ const { inSharedRecord } = useRecordCapabilities();
const coachUnavailable = !flags.coach || disableCoach;
@@ -170,11 +174,13 @@ export default function CoachPageClient() {
// operator master flag OR per-user opt-out hides the Coach entirely.
// Send a direct navigator back to the Insights mother page so the
// route is never a dead-end.
+ const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord;
+
useEffect(() => {
- if (coachUnavailable) {
+ if (canRenderOwnCoach && coachUnavailable) {
router.replace("/insights");
}
- }, [coachUnavailable, router]);
+ }, [canRenderOwnCoach, coachUnavailable, router]);
// Reopening the bottom-right FAB drawer is no longer wired to a page
// control, but keep the launch context referenced so the lint rule does
@@ -182,7 +188,7 @@ export default function CoachPageClient() {
// the shared ``.
void launch;
- if (coachUnavailable) return null;
+ if (!canRenderOwnCoach || coachUnavailable) return null;
return (
({
useAuth: () => ({
@@ -63,6 +66,7 @@ vi.mock("@/hooks/use-auth", () => ({
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
+ accountAccessStatus: mockAccountAccessStatusRef.value,
},
isAuthenticated: true,
isAuthUnknown: false,
@@ -138,9 +142,14 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
canWrite: true,
};
-function render(access: AccountAccess, pathname = "/"): string {
+function render(
+ access: AccountAccess,
+ pathname = "/",
+ accountAccessStatus: "absent" | "valid" | "invalid" = "valid",
+): string {
mockAccessRef.value = access;
mockPathRef.value = pathname;
+ mockAccountAccessStatusRef.value = accountAccessStatus;
return renderToStaticMarkup(
@@ -205,6 +214,18 @@ describe(" — the owner-only doors", () => {
}
});
+ it("refuses an invalid record block without mounting owner-only doors or children", () => {
+ const html = render(OWN_RECORD, "/", "invalid");
+
+ expect(html).toContain("sentinel-unavailable");
+ expect(html).not.toContain("sentinel-page");
+ for (const slot of DOORS) {
+ expect(html, `${slot} must not paint for a refused record`).not.toContain(
+ slot,
+ );
+ }
+ });
+
it.each([
["read", OWNER_READ],
["write", OWNER_WRITE],
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index e8a56cb95..f913d29e3 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -79,7 +79,8 @@ export function AuthShell({
// decides what to mount and the surfaces beneath it decide what to offer,
// and the two disagreeing is the whole class of defect this hook exists to
// close.
- const { inSharedRecord, recordKind, sections } = useRecordCapabilities();
+ const { inSharedRecord, recordKind, sections, accessRefused } =
+ useRecordCapabilities();
// A managed profile has a deliberately small Settings surface of its own.
// Let that route family reach its record-aware gate; adult shared records
// stay in the generic refusal branch even at MANAGE.
@@ -245,6 +246,23 @@ export function AuthShell({
);
}
+ // A present but malformed access block is not the older-server case. The
+ // server may still be switched, so mounting children here could fetch target
+ // data under owner controls. Offer only the actor-scoped way out.
+ if (accessRefused) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
return (
diff --git a/src/hooks/__tests__/use-auth-account-access-state.test.ts b/src/hooks/__tests__/use-auth-account-access-state.test.ts
new file mode 100644
index 000000000..b9ec29a73
--- /dev/null
+++ b/src/hooks/__tests__/use-auth-account-access-state.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ __resetRecordScopeForTests,
+ getRecordScope,
+ isReadingSharedRecord,
+} from "@/lib/query-keys/record-scope";
+
+const apiGet = vi.fn();
+
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiGet: (...args: unknown[]) => apiGet(...args),
+ apiFetchRaw: vi.fn(),
+ ApiError: class ApiError extends Error {},
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+import { fetchMe, NO_ACCOUNT_ACCESS } from "../use-auth";
+
+const response = {
+ id: "delegate",
+ username: "delegate",
+ role: "USER",
+ timezone: "Europe/Berlin",
+};
+
+const canonicalEntry = {
+ accountId: "record-a",
+ username: "record-a",
+ displayName: "Record A",
+ access: "read",
+ level: "read",
+ recordKind: "shared",
+ sections: ["labs"],
+ canWrite: false,
+};
+
+beforeEach(() => {
+ apiGet.mockReset();
+ __resetRecordScopeForTests();
+});
+
+describe("fetchMe account-access resolution", () => {
+ it("keeps a genuinely absent block compatible with an older server", async () => {
+ apiGet.mockResolvedValue(response);
+
+ const user = await fetchMe();
+
+ expect(user.accountAccessStatus).toBe("absent");
+ expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
+ expect(getRecordScope()).toBeNull();
+ expect(isReadingSharedRecord()).toBe(false);
+ });
+
+ it("uses a valid published block as the active record scope", async () => {
+ apiGet.mockResolvedValue({
+ ...response,
+ accountAccess: {
+ accounts: [canonicalEntry],
+ active: canonicalEntry,
+ recordKind: "shared",
+ canSwitch: true,
+ },
+ });
+
+ const user = await fetchMe();
+
+ expect(user.accountAccessStatus).toBe("valid");
+ expect(user.accountAccess?.active).toEqual(canonicalEntry);
+ expect(getRecordScope()).toBe("record-a");
+ });
+
+ it.each([
+ ["an explicitly undefined block", undefined],
+ ["a null block", null],
+ [
+ "a divergent active entry",
+ {
+ accounts: [canonicalEntry],
+ active: {
+ ...canonicalEntry,
+ access: "write",
+ level: "write",
+ canWrite: true,
+ },
+ recordKind: "shared",
+ canSwitch: true,
+ },
+ ],
+ ])(
+ "refuses %s without restoring an owner cache scope",
+ async (_name, block) => {
+ apiGet.mockResolvedValue({ ...response, accountAccess: block });
+
+ const user = await fetchMe();
+
+ expect(user.accountAccessStatus).toBe("invalid");
+ expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
+ expect(getRecordScope()).not.toBeNull();
+ expect(isReadingSharedRecord()).toBe(true);
+ },
+ );
+});
diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts
index 90dc83abc..1b4600950 100644
--- a/src/hooks/__tests__/use-record-capabilities.test.ts
+++ b/src/hooks/__tests__/use-record-capabilities.test.ts
@@ -72,6 +72,19 @@ describe("resolveRecordCapabilities", () => {
);
});
+ it("refuses a malformed published block instead of treating it as own record", () => {
+ expect(resolveRecordCapabilities(null, true)).toEqual({
+ inSharedRecord: true,
+ canWrite: false,
+ canAdd: false,
+ canManage: false,
+ level: null,
+ sections: [],
+ recordKind: "shared",
+ accessRefused: true,
+ });
+ });
+
it("a read-only delegate adds nothing", () => {
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
inSharedRecord: true,
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 18899c26c..453476e0d 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -23,7 +23,10 @@ import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
-import { setRecordScope } from "@/lib/query-keys/record-scope";
+import {
+ setRecordScope,
+ setRefusedRecordScope,
+} from "@/lib/query-keys/record-scope";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
@@ -39,6 +42,9 @@ export const NO_ACCOUNT_ACCESS: AccountAccess = {
canSwitch: false,
};
+/** Whether the account-access block was absent, valid, or refused as malformed. */
+export type AccountAccessStatus = "absent" | "valid" | "invalid";
+
/**
* v1.18.6 — resume point for the module tour as the client sees it on
* `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
@@ -193,6 +199,13 @@ export interface AuthUser {
* the empty one — no accounts, no active record, no switcher.
*/
accountAccess?: AccountAccess;
+ /**
+ * The parser outcome for `accountAccess`. An absent block is the only
+ * owner-compatible fallback: it is how older server images answered before
+ * sharing existed. A present malformed block stays explicitly refused so a
+ * server-side switch cannot paint owner controls in the target record.
+ */
+ accountAccessStatus?: AccountAccessStatus;
}
/**
@@ -253,7 +266,7 @@ export function isAuthVerdictUnknown(error: unknown): boolean {
return true;
}
-async function fetchMe(): Promise {
+export async function fetchMe(): Promise {
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
// throws `ApiError`, which `useAuth` treats as "not authenticated"
// exactly like the old hand-rolled throw.
@@ -300,8 +313,13 @@ 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.
- const accountAccess = coerceAccountAccess(data.accountAccess);
- setRecordScope(accountAccess.active?.accountId ?? null);
+ const { accountAccess, status: accountAccessStatus } =
+ resolveAccountAccess(data);
+ if (accountAccessStatus === "invalid") {
+ setRefusedRecordScope();
+ } else {
+ setRecordScope(accountAccess.active?.accountId ?? null);
+ }
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
@@ -329,21 +347,32 @@ async function fetchMe(): Promise {
? data.moduleAvailability
: {},
accountAccess,
+ accountAccessStatus,
};
}
/**
* Read the sharing block off a payload that may not carry one.
*
- * Fails CLOSED, unlike the module maps above: an older server image (or a
- * malformed block) resolves to no accounts, no active record, and no switcher.
- * The module gates fail open because a missing map must never blank the nav;
- * this one fails closed because the affordance it drives asks the server to
- * open somebody else's health record, and an affordance offered on a server
- * that cannot honour it is worse than one that is missing.
+ * A genuinely missing field is the pre-sharing server contract and remains an
+ * own-record fallback. Once a server publishes the field, every other value —
+ * including `undefined` as an own property and `null` — is a malformed block,
+ * not permission to fall back to the caller's own presentation.
*/
-function coerceAccountAccess(value: unknown): AccountAccess {
- return parseAccountAccess(value) ?? NO_ACCOUNT_ACCESS;
+function resolveAccountAccess(value: object): {
+ accountAccess: AccountAccess;
+ status: AccountAccessStatus;
+} {
+ if (!Object.hasOwn(value, "accountAccess")) {
+ return { accountAccess: NO_ACCOUNT_ACCESS, status: "absent" };
+ }
+
+ const accountAccess = parseAccountAccess(
+ (value as { accountAccess?: unknown }).accountAccess,
+ );
+ return accountAccess
+ ? { accountAccess, status: "valid" }
+ : { accountAccess: NO_ACCOUNT_ACCESS, status: "invalid" };
}
export function useAuth() {
diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts
index 53219288d..8c536b850 100644
--- a/src/hooks/use-record-capabilities.ts
+++ b/src/hooks/use-record-capabilities.ts
@@ -71,6 +71,8 @@ import {
* search a button in gets a sentence.
*/
export interface RecordCapabilities {
+ /** A present account-access block was malformed and must not mount a record. */
+ accessRefused?: boolean;
/** Is this browser acting on somebody else's record right now. */
inSharedRecord: boolean;
/** The grant's resolved level for the record on screen. */
@@ -111,7 +113,20 @@ export interface RecordCapabilities {
*/
export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
+ accessRefused = false,
): RecordCapabilities {
+ if (accessRefused) {
+ return {
+ accessRefused: true,
+ inSharedRecord: true,
+ canWrite: false,
+ canAdd: false,
+ canManage: false,
+ level: null,
+ sections: [],
+ recordKind: "shared",
+ };
+ }
if (!active) {
return {
inSharedRecord: false,
@@ -156,7 +171,10 @@ export function resolveRecordCapabilities(
*/
export function useRecordCapabilities(): RecordCapabilities {
const { user } = useAuth();
- return resolveRecordCapabilities(user?.accountAccess?.active);
+ return resolveRecordCapabilities(
+ user?.accountAccess?.active,
+ user?.accountAccessStatus === "invalid",
+ );
}
/**
diff --git a/src/lib/query-keys/__tests__/record-scope.test.ts b/src/lib/query-keys/__tests__/record-scope.test.ts
index befe0bc56..79d85b24b 100644
--- a/src/lib/query-keys/__tests__/record-scope.test.ts
+++ b/src/lib/query-keys/__tests__/record-scope.test.ts
@@ -21,6 +21,7 @@ import {
getRecordScope,
isReadingSharedRecord,
recordScopedQueryKeyHashFn,
+ setRefusedRecordScope,
setRecordScope,
} from "@/lib/query-keys/record-scope";
@@ -101,6 +102,24 @@ describe("the cache cannot serve one record's data while reading another", () =>
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
});
+ it("never puts data from a refused record into the caller's own cache slot", () => {
+ const client = appClient();
+ client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
+
+ setRefusedRecordScope();
+
+ expect(isReadingSharedRecord()).toBe(true);
+ expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
+ });
+
+ it("keeps the auth identity reachable when a refused record changes scope", () => {
+ const ownAuthHash = recordScopedQueryKeyHashFn(queryKeys.authMe());
+
+ setRefusedRecordScope();
+
+ expect(recordScopedQueryKeyHashFn(queryKeys.authMe())).toBe(ownAuthHash);
+ });
+
it("keeps two records' entries apart at the same time", () => {
const client = appClient();
setRecordScope("acct-a");
diff --git a/src/lib/query-keys/record-scope.ts b/src/lib/query-keys/record-scope.ts
index 93468e317..bad85d65c 100644
--- a/src/lib/query-keys/record-scope.ts
+++ b/src/lib/query-keys/record-scope.ts
@@ -52,6 +52,7 @@
import type { QueryKey } from "@tanstack/react-query";
const STORAGE_KEY = "healthlog-record-scope";
+const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
/**
* The in-process copy. Seeded from storage on first read so the hash function
@@ -113,6 +114,18 @@ export function setRecordScope(accountId: string | null): void {
}
}
+/**
+ * Keep a malformed active-record response out of the caller's own cache slot.
+ *
+ * No record id from an invalid payload is trusted, but `null` would make
+ * record-scoped reads share the caller's own cache identity. This sentinel is
+ * a cache partition only; the shell refuses the presentation until the caller
+ * leaves the server-side switch.
+ */
+export function setRefusedRecordScope(): void {
+ setRecordScope(REFUSED_RECORD_SCOPE);
+}
+
/** True while this browser is reading somebody else's record. */
export function isReadingSharedRecord(): boolean {
return getRecordScope() !== null;
@@ -138,6 +151,10 @@ export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string {
}, {})
: val,
);
+ // `/api/auth/me` identifies the caller, never the record they are reading.
+ // Its query begins before `fetchMe` can resolve or refuse the record scope,
+ // so it must remain reachable across that transition.
+ if (queryKey[0] === "auth") return key;
const record = getRecordScope();
return record === null ? key : `record:${record}|${key}`;
}
From c42df8cbc34e54c9e963511e734d6b7707816720 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 14:32:32 +0200
Subject: [PATCH 33/54] fix(02-12): gate record preload scope
- Wait for a validated account scope before warming record reads
- Abort scoped preloads and discard handoffs across record transitions
- Cover malformed scope reloads and fresh own-record recovery
---
e2e/v137-sharing-managed-profiles.spec.ts | 66 +++++++++++++--
.../providers-record-preloader.test.ts | 80 +++++++++++++++++++
src/components/providers.tsx | 62 ++++++++++++--
.../__tests__/prefetch-medications.test.ts | 72 +++++++++++++++++
.../__tests__/use-dashboard-snapshot.test.tsx | 31 +++++++
src/lib/queries/prefetch-medications.ts | 9 ++-
src/lib/queries/use-dashboard-snapshot.ts | 29 +++++--
src/lib/query-keys/record-scope.ts | 33 ++++++++
8 files changed, 361 insertions(+), 21 deletions(-)
create mode 100644 src/components/__tests__/providers-record-preloader.test.ts
create mode 100644 src/lib/queries/__tests__/prefetch-medications.test.ts
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 2a2a503d2..8850556f1 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -138,7 +138,7 @@ test.describe.serial("scoped sharing browser journeys", () => {
});
}
- test("refuses a malformed switched payload before owner controls or reads mount", async ({
+ test("refuses a malformed switched payload before target or owner reads mount", async ({
page,
}) => {
await page.goto("/");
@@ -149,14 +149,20 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(entry).toHaveCount(1);
- let corruptNextAuthPayload = true;
+ // The switch normally writes this mirror before its full reload. Remove it
+ // in the new document so the global preloaders cannot guess an own-record
+ // scope while `/api/auth/me` is still deciding whether the target is safe.
+ await page.addInitScript(() => {
+ localStorage.removeItem("healthlog-record-scope");
+ });
+
+ let corruptAuthPayload = true;
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
- if (!corruptNextAuthPayload) {
+ if (!corruptAuthPayload) {
await route.fulfill({ response });
return;
}
- corruptNextAuthPayload = false;
await route.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
@@ -182,6 +188,24 @@ test.describe.serial("scoped sharing browser journeys", () => {
}
};
page.on("request", trackOwnerOnlyRead);
+ const targetPreloads: string[] = [];
+ const trackTargetPreload = (request: {
+ method(): string;
+ url(): string;
+ }) => {
+ if (request.method() !== "GET") return;
+ const path = new URL(request.url()).pathname;
+ if (
+ [
+ "/api/dashboard/snapshot",
+ "/api/medications",
+ "/api/medications/compliance",
+ ].includes(path)
+ ) {
+ targetPreloads.push(path);
+ }
+ };
+ page.on("request", trackTargetPreload);
try {
await entry.click();
@@ -196,14 +220,46 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveCount(0);
- expect(ownerOnlyReads).toEqual([]);
+ // Both global preloader routes must remain inert while the malformed
+ // response is refused, even though the scope mirror was intentionally
+ // cleared before each reload.
+ await page.goto("/");
+ await expect(
+ page.locator('[data-slot="invalid-record-access-refusal"]'),
+ ).toBeVisible();
+ await page.goto("/medications");
+ await expect(
+ page.locator('[data-slot="invalid-record-access-refusal"]'),
+ ).toBeVisible();
+ expect(ownerOnlyReads).toEqual([]);
+ expect(targetPreloads).toEqual([]);
+
+ // Leave the refused record, then demand fresh own-record reads. This
+ // catches a response that was parked under the owner hash while the
+ // refusal was on screen and would otherwise paint without a new request.
+ corruptAuthPayload = false;
+ const freshDashboard = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname === "/api/dashboard/snapshot",
+ );
await page
.locator('[data-slot="shared-record-unavailable-leave"]')
.click();
await expect(page).toHaveURL(/\/$/);
+ await freshDashboard;
+
+ const freshMedications = page.waitForResponse(
+ (response) =>
+ response.request().method() === "GET" &&
+ new URL(response.url()).pathname === "/api/medications",
+ );
+ await page.goto("/medications");
+ await freshMedications;
} finally {
page.off("request", trackOwnerOnlyRead);
+ page.off("request", trackTargetPreload);
await page.unroute("**/api/auth/me");
}
});
diff --git a/src/components/__tests__/providers-record-preloader.test.ts b/src/components/__tests__/providers-record-preloader.test.ts
new file mode 100644
index 000000000..f33ab9434
--- /dev/null
+++ b/src/components/__tests__/providers-record-preloader.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import { isRecordPreloadReady } from "@/components/providers";
+import {
+ __resetRecordScopeForTests,
+ setRecordScope,
+ setRefusedRecordScope,
+} from "@/lib/query-keys/record-scope";
+
+describe("isRecordPreloadReady", () => {
+ it("waits for the account response before warming record reads", () => {
+ __resetRecordScopeForTests();
+
+ expect(
+ isRecordPreloadReady({ isLoading: true, accountAccessStatus: "valid" }),
+ ).toBe(false);
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: undefined,
+ }),
+ ).toBe(false);
+ });
+
+ it("accepts only a resolved own-record response with an own-record scope", () => {
+ __resetRecordScopeForTests();
+ setRecordScope(null);
+
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: "valid",
+ activeAccountId: null,
+ }),
+ ).toBe(true);
+
+ setRecordScope("shared-record");
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: "valid",
+ activeAccountId: null,
+ }),
+ ).toBe(false);
+ });
+
+ it("accepts a shared-record response only after its exact scope is mirrored", () => {
+ __resetRecordScopeForTests();
+ setRecordScope("shared-record");
+
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: "valid",
+ activeAccountId: "shared-record",
+ }),
+ ).toBe(true);
+
+ setRecordScope(null);
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: "valid",
+ activeAccountId: "shared-record",
+ }),
+ ).toBe(false);
+ });
+
+ it("never opens a preload window for an explicitly refused record", () => {
+ __resetRecordScopeForTests();
+ setRefusedRecordScope();
+
+ expect(
+ isRecordPreloadReady({
+ isLoading: false,
+ accountAccessStatus: "invalid",
+ activeAccountId: null,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 37dfa184d..39407f5a6 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -22,9 +22,14 @@ 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 { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
+import {
+ getRecordScope,
+ subscribeToRecordScope,
+} from "@/lib/query-keys/record-scope";
import {
restorePersistedQueryCache,
startPersistingQueryCache,
@@ -168,15 +173,57 @@ function ThemeProvider({ children }: { children: ReactNode }) {
// the router commits to "/" instead of waiting for the dashboard page
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
// The proxy has already enforced auth + onboarding for "/" before any
-// client code runs here, so the prefetch cannot fire for an
-// unauthenticated visitor; a racing edge case just yields a swallowed
-// 401 prefetch and the mounted cell re-resolves normally.
+// client code runs here. It does not, however, resolve the active shared
+// record: that verdict arrives from `/api/auth/me`. Do not begin a record read
+// until that answer and the local cache scope agree.
+export function isRecordPreloadReady({
+ isLoading,
+ accountAccessStatus,
+ activeAccountId = null,
+}: {
+ isLoading: boolean;
+ accountAccessStatus: AccountAccessStatus | undefined;
+ activeAccountId?: string | null;
+}): boolean {
+ return (
+ !isLoading &&
+ (accountAccessStatus === "absent" || accountAccessStatus === "valid") &&
+ getRecordScope() === activeAccountId
+ );
+}
+
+function subscribeToClientRecordScope(onStoreChange: () => void): () => void {
+ return subscribeToRecordScope(onStoreChange);
+}
+
+function getClientRecordScope(): string | null {
+ return getRecordScope();
+}
+
+function getServerRecordScope(): string | null {
+ return null;
+}
+
function DashboardSnapshotPreloader() {
const queryClient = useQueryClient();
const pathname = usePathname();
+ const { user, isLoading } = useAuth();
+ const recordScope = useSyncExternalStore(
+ subscribeToClientRecordScope,
+ getClientRecordScope,
+ getServerRecordScope,
+ );
+ const ready = isRecordPreloadReady({
+ isLoading,
+ accountAccessStatus: user?.accountAccessStatus,
+ activeAccountId: user?.accountAccess?.active?.accountId ?? null,
+ });
+
useEffect(() => {
+ if (!ready) return;
+ const controller = new AbortController();
if (pathname === "/" && isDashboardSnapshotEnabled()) {
- prefetchDashboardSnapshot(queryClient);
+ prefetchDashboardSnapshot(queryClient, controller.signal);
}
// v1.16.7 — same waterfall cut for the medications page: its list
// query (which carries the per-medication `nextDueAt` the due cells
@@ -185,9 +232,12 @@ function DashboardSnapshotPreloader() {
// download; the nav links additionally prefetch on hover/touch
// intent, so this is the fallback for direct loads + reloads.
if (pathname === "/medications") {
- prefetchMedicationsList(queryClient);
+ prefetchMedicationsList(queryClient, controller.signal);
}
- }, [pathname, queryClient]);
+ // A transition to an unresolved, refused, or different record scope must
+ // abort a preloader before its response can be adopted by the next route.
+ return () => controller.abort();
+ }, [pathname, queryClient, ready, recordScope]);
return null;
}
diff --git a/src/lib/queries/__tests__/prefetch-medications.test.ts b/src/lib/queries/__tests__/prefetch-medications.test.ts
new file mode 100644
index 000000000..bf718c69c
--- /dev/null
+++ b/src/lib/queries/__tests__/prefetch-medications.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { QueryClient } from "@tanstack/react-query";
+import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ __resetRecordScopeForTests,
+ setRecordScope,
+} from "@/lib/query-keys/record-scope";
+import { prefetchMedicationsList } from "../prefetch-medications";
+
+function envelopeResponse(data: unknown): Response {
+ return new Response(JSON.stringify({ data, error: null }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+afterEach(() => {
+ __resetRecordScopeForTests();
+ vi.unstubAllGlobals();
+});
+
+describe("prefetchMedicationsList", () => {
+ it("does not park an aborted shared-record preload in the own-record cache", async () => {
+ setRecordScope("shared-record");
+ const controller = new AbortController();
+ const fetchMock = vi.fn(
+ (_path: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => {
+ reject(new DOMException("Aborted", "AbortError"));
+ });
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const client = new QueryClient({
+ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
+ });
+
+ prefetchMedicationsList(client, controller.signal);
+ controller.abort();
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ setRecordScope(null);
+
+ expect(client.getQueryData(queryKeys.medications())).toBeUndefined();
+ expect(
+ client.getQueryData(queryKeys.medicationComplianceSummary()),
+ ).toBeUndefined();
+ });
+
+ it("passes a lifecycle signal to both record reads", async () => {
+ const controller = new AbortController();
+ const fetchMock = vi.fn().mockResolvedValue(envelopeResponse([]));
+ vi.stubGlobal("fetch", fetchMock);
+ const client = new QueryClient({
+ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
+ });
+
+ prefetchMedicationsList(client, controller.signal);
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+ signal: controller.signal,
+ });
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
+ signal: controller.signal,
+ });
+ });
+});
diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx
index ba22b54c1..9205493a0 100644
--- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx
+++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx
@@ -43,6 +43,10 @@ import {
} from "../use-dashboard-snapshot";
import { retryOnceOnTransientError } from "../retry-transient";
import { queryKeys } from "@/lib/query-keys";
+import {
+ __resetRecordScopeForTests,
+ setRecordScope,
+} from "@/lib/query-keys/record-scope";
import type { QueryClient } from "@tanstack/react-query";
/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */
@@ -58,6 +62,7 @@ afterEach(() => {
getQueryDataMock.mockReset();
setQueryDataMock.mockClear();
_resetDashboardSnapshotPreloadForTests();
+ __resetRecordScopeForTests();
vi.unstubAllGlobals();
});
@@ -257,6 +262,32 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
expect(snap.layout).toEqual(layout);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
+
+ it("drops a parked shared-record response after the record scope changes", async () => {
+ setRecordScope("shared-record");
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ envelopeResponse({ layout: { owner: true }, tiles: {} }),
+ )
+ .mockResolvedValueOnce(
+ envelopeResponse({ layout: { owner: false }, tiles: {} }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
+
+ prefetchDashboardSnapshot(fakeQueryClient());
+ setRecordScope(null);
+
+ useDashboardSnapshot();
+ const opts = lastOptsOf(useQueryMock);
+ const snapshot = await (
+ opts.queryFn as () => Promise<{ layout: unknown }>
+ )();
+
+ expect(snapshot.layout).toEqual({ owner: false });
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
});
function lastOptsOf(mock: typeof useQueryMock): Record {
diff --git a/src/lib/queries/prefetch-medications.ts b/src/lib/queries/prefetch-medications.ts
index 4ac9bd05a..b9902f6e3 100644
--- a/src/lib/queries/prefetch-medications.ts
+++ b/src/lib/queries/prefetch-medications.ts
@@ -29,15 +29,18 @@ import { apiGet } from "@/lib/api/api-fetch";
*/
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
-export function prefetchMedicationsList(queryClient: QueryClient): void {
+export function prefetchMedicationsList(
+ queryClient: QueryClient,
+ signal?: AbortSignal,
+): void {
void queryClient.prefetchQuery({
queryKey: queryKeys.medications(),
- queryFn: () => apiGet("/api/medications"),
+ queryFn: () => apiGet("/api/medications", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
void queryClient.prefetchQuery({
queryKey: queryKeys.medicationComplianceSummary(),
- queryFn: () => apiGet("/api/medications/compliance"),
+ queryFn: () => apiGet("/api/medications/compliance", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
}
diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts
index 62c99c199..30b476ed8 100644
--- a/src/lib/queries/use-dashboard-snapshot.ts
+++ b/src/lib/queries/use-dashboard-snapshot.ts
@@ -40,16 +40,19 @@ import {
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { getRecordScope } from "@/lib/query-keys/record-scope";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
-async function fetchDashboardSnapshot(): Promise {
+async function fetchDashboardSnapshot(
+ signal?: AbortSignal,
+): Promise {
// Routed through the typed wrapper so a failure carries its HTTP
// status (`ApiError`) for the transient-retry predicate, and the
// default 15 s timeout keeps a stalled response from pinning the
// whole dashboard on its skeleton.
- return apiGet("/api/dashboard/snapshot");
+ return apiGet("/api/dashboard/snapshot", { signal });
}
/** Handoff freshness window — mirrors the hook's `staleTime`. */
@@ -68,6 +71,7 @@ const PRELOAD_MAX_AGE_MS = 60_000;
let preloadedSnapshot: {
promise: Promise;
startedAt: number;
+ recordScope: string | null;
} | null = null;
export function _resetDashboardSnapshotPreloadForTests(): void {
@@ -88,7 +92,10 @@ export function _resetDashboardSnapshotPreloadForTests(): void {
* `queryFn` consumes it; preload errors are swallowed by design — the
* mounted cell owns error surfacing with its own fresh attempt.
*/
-export function prefetchDashboardSnapshot(queryClient: QueryClient) {
+export function prefetchDashboardSnapshot(
+ queryClient: QueryClient,
+ signal?: AbortSignal,
+) {
// Expired handoff from a previous visit — drop it so a long-idle slot
// can never serve stale data.
if (
@@ -128,22 +135,30 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
if (freshest > 0) {
return;
}
- const promise = fetchDashboardSnapshot();
+ const recordScope = getRecordScope();
+ const promise = fetchDashboardSnapshot(signal);
promise.catch(() => {
// Swallow + clear so an unconsumed failed preload (racing 401 on
// login, transient network) never becomes an unhandled rejection and
// never gets handed to a later mount.
if (preloadedSnapshot?.promise === promise) preloadedSnapshot = null;
});
- preloadedSnapshot = { promise, startedAt: Date.now() };
+ preloadedSnapshot = { promise, startedAt: Date.now(), recordScope };
}
/** Single-use consume of the preload handoff; `null` when absent/expired. */
function takePreloadedSnapshot(): Promise | null {
if (!preloadedSnapshot) return null;
- const { promise, startedAt } = preloadedSnapshot;
+ const { promise, startedAt, recordScope } = preloadedSnapshot;
preloadedSnapshot = null;
- return Date.now() - startedAt < PRELOAD_MAX_AGE_MS ? promise : null;
+ // A handoff is not query-cached, so it needs the same record boundary as a
+ // query cache entry. A scope move while it is in flight must make the next
+ // mounted dashboard fetch afresh instead of adopting a response from the
+ // record that just ended.
+ return Date.now() - startedAt < PRELOAD_MAX_AGE_MS &&
+ recordScope === getRecordScope()
+ ? promise
+ : null;
}
function makeSnapshotQueryFn(queryClient: QueryClient) {
diff --git a/src/lib/query-keys/record-scope.ts b/src/lib/query-keys/record-scope.ts
index bad85d65c..35c5d74b6 100644
--- a/src/lib/query-keys/record-scope.ts
+++ b/src/lib/query-keys/record-scope.ts
@@ -60,6 +60,11 @@ const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
*/
let scope: string | null = null;
let seeded = false;
+const scopeListeners = new Set<() => void>();
+
+function notifyScopeListeners(): void {
+ for (const listener of scopeListeners) listener();
+}
function readMirror(): string | null {
try {
@@ -112,6 +117,34 @@ export function setRecordScope(accountId: string | null): void {
} catch {
/* storage unavailable — the in-process copy still partitions this tab */
}
+ notifyScopeListeners();
+}
+
+/**
+ * React bridge for consumers that must stop work as soon as another tab moves
+ * the session to a different record. `storage` never fires in the tab that
+ * wrote the mirror, so local writes notify directly above; other tabs adopt
+ * the new value before notifying their subscribers.
+ */
+export function subscribeToRecordScope(listener: () => void): () => void {
+ scopeListeners.add(listener);
+ if (typeof window === "undefined") {
+ return () => scopeListeners.delete(listener);
+ }
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== STORAGE_KEY) return;
+ scope =
+ event.newValue === null || event.newValue.length === 0
+ ? null
+ : event.newValue;
+ seeded = true;
+ notifyScopeListeners();
+ };
+ window.addEventListener("storage", onStorage);
+ return () => {
+ scopeListeners.delete(listener);
+ window.removeEventListener("storage", onStorage);
+ };
}
/**
From a636a3b4c5814a60f205acbc527a5a5b71b73b92 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 14:35:02 +0200
Subject: [PATCH 34/54] fix(02-12): gate shared record navigation
- Wait for the shared-record banner before generated reads
- Keep the profile doorway in the navigation translation namespace
---
e2e/v137-sharing-managed-profiles.spec.ts | 1 +
messages/de.json | 1 +
messages/en.json | 1 +
src/components/layout/nav-model.ts | 2 +-
4 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts
index 8850556f1..f21d6137c 100644
--- a/e2e/v137-sharing-managed-profiles.spec.ts
+++ b/e2e/v137-sharing-managed-profiles.spec.ts
@@ -356,6 +356,7 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveAttribute("data-record-kind", "shared");
await page.goto("/");
+ await expect(banner).toHaveAttribute("data-access-level", "manage");
const generatedRead = await page.evaluate(async () => {
const response = await fetch("/api/dashboard/summary");
return response.status;
diff --git a/messages/de.json b/messages/de.json
index 6d8c53f1e..2e876a92b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -341,6 +341,7 @@
},
"cycle": "Zyklus",
"labs": "Laborwerte",
+ "profile": "Profil",
"vorsorge": "Vorsorge",
"illness": "Krankheit",
"documents": "Dokumente",
diff --git a/messages/en.json b/messages/en.json
index a0e7bc356..704da23e3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -341,6 +341,7 @@
},
"cycle": "Cycle",
"labs": "Labs",
+ "profile": "Profile",
"vorsorge": "Checkups",
"illness": "Illness",
"documents": "Documents",
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
index e286ae2a3..4ab0b709a 100644
--- a/src/components/layout/nav-model.ts
+++ b/src/components/layout/nav-model.ts
@@ -177,7 +177,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [
href: "/profile",
sharedRecord: true,
sharedRecordOnly: true,
- tKey: "settings.sections.anamnesis.title",
+ tKey: "nav.profile",
icon: ClipboardList,
tourId: "nav-profile",
},
From c66b29d22a3a3c9110068d3a30365d06382438bd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 14:37:13 +0200
Subject: [PATCH 35/54] fix(02-12): complete profile navigation translations
- Keep every supported locale aligned with the profile navigation doorway
---
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
4 files changed, 4 insertions(+)
diff --git a/messages/es.json b/messages/es.json
index 625c3daff..13cb2d2cb 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -341,6 +341,7 @@
},
"cycle": "Ciclo",
"labs": "Análisis",
+ "profile": "Perfil",
"vorsorge": "Revisiones",
"illness": "Enfermedad",
"documents": "Documentos",
diff --git a/messages/fr.json b/messages/fr.json
index bc662c21a..0c97286b6 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -341,6 +341,7 @@
},
"cycle": "Cycle",
"labs": "Analyses",
+ "profile": "Profil",
"vorsorge": "Examens",
"illness": "Maladie",
"documents": "Documents",
diff --git a/messages/it.json b/messages/it.json
index 2b0202731..9bb80dbf5 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -341,6 +341,7 @@
},
"cycle": "Ciclo",
"labs": "Esami",
+ "profile": "Profilo",
"vorsorge": "Controlli",
"illness": "Malattia",
"documents": "Documenti",
diff --git a/messages/pl.json b/messages/pl.json
index 526608239..f01fa2c7f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -341,6 +341,7 @@
},
"cycle": "Cykl",
"labs": "Wyniki badań",
+ "profile": "Profil",
"vorsorge": "Badania",
"illness": "Choroba",
"documents": "Dokumenty",
From 38214e4726b9c68c5c2952fdc3f42b4aeb761daf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 15:06:12 +0200
Subject: [PATCH 36/54] fix(02-12): hold peer tabs during record switches
- Clear scoped reads before the server session changes
- Reconcile every tab with a fresh access response
---
e2e/v137-sharing-cross-tab-session.spec.ts | 116 +++++++
.../auth-shell-shared-record-doors.test.tsx | 26 ++
src/components/layout/auth-shell.tsx | 17 +-
src/components/providers.tsx | 77 ++++-
src/hooks/use-account-switch.ts | 73 +++--
src/hooks/use-auth.ts | 13 +
src/hooks/use-record-capabilities.ts | 11 +-
src/hooks/use-record-session-transition.ts | 27 ++
src/lib/pwa/query-persister.ts | 3 +-
src/lib/queries/use-dashboard-snapshot.ts | 5 +
.../record-session-transition.test.ts | 96 ++++++
src/lib/query-keys/record-scope.ts | 10 +-
.../query-keys/record-session-transition.ts | 282 ++++++++++++++++++
13 files changed, 711 insertions(+), 45 deletions(-)
create mode 100644 e2e/v137-sharing-cross-tab-session.spec.ts
create mode 100644 src/hooks/use-record-session-transition.ts
create mode 100644 src/lib/query-keys/__tests__/record-session-transition.test.ts
create mode 100644 src/lib/query-keys/record-session-transition.ts
diff --git a/e2e/v137-sharing-cross-tab-session.spec.ts b/e2e/v137-sharing-cross-tab-session.spec.ts
new file mode 100644
index 000000000..a483ad98e
--- /dev/null
+++ b/e2e/v137-sharing-cross-tab-session.spec.ts
@@ -0,0 +1,116 @@
+import type { Page } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
+
+async function openSwitcher(page: Page) {
+ await page.getByRole("button", { name: "User menu" }).first().click();
+ await page.locator('[data-slot="account-switcher-trigger"]').click();
+}
+
+function deferred() {
+ let release!: () => void;
+ const promise = new Promise((resolve) => {
+ release = resolve;
+ });
+ return { promise, release };
+}
+
+test.describe.serial("cross-tab shared-record session switches", () => {
+ test("holds a peer tab before both switch-in and switch-out change the shared session", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const switchingPage = await context.newPage();
+ const peerPage = await context.newPage();
+ const pausedSwitches: ReturnType[] = [];
+ const peerRecordReads: string[] = [];
+
+ await switchingPage.route("**/api/account/switch", async (route) => {
+ const pause = pausedSwitches.shift();
+ if (pause) await pause.promise;
+ await route.continue();
+ });
+ peerPage.on("request", (request) => {
+ if (request.method() !== "GET") return;
+ const path = new URL(request.url()).pathname;
+ if (
+ [
+ "/api/dashboard/snapshot",
+ "/api/insights/targets",
+ "/api/medications",
+ "/api/medications/compliance",
+ "/api/labs",
+ "/api/labs/ocr/capability",
+ "/api/insights/coach/nudge-status",
+ ].includes(path)
+ ) {
+ peerRecordReads.push(path);
+ }
+ });
+
+ try {
+ await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]);
+ await expect(
+ peerPage.getByRole("button", { name: "User menu" }),
+ ).toBeVisible();
+
+ await openSwitcher(switchingPage);
+ const entry = switchingPage.locator(
+ '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]',
+ );
+ await expect(entry).toBeVisible();
+ const switchIn = deferred();
+ pausedSwitches.push(switchIn);
+ peerRecordReads.length = 0;
+ const switchInClick = entry.click({ noWaitAfter: true });
+
+ await expect(
+ peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
+ ).toBeVisible();
+ await expect(
+ peerPage.getByRole("button", { name: "User menu" }),
+ ).toHaveCount(0);
+ await peerPage.waitForTimeout(200);
+ expect(peerRecordReads).toEqual([]);
+
+ switchIn.release();
+ await switchInClick;
+ await expect(
+ switchingPage.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+ await expect(
+ peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
+ ).toHaveCount(0);
+
+ const switchOut = deferred();
+ pausedSwitches.push(switchOut);
+ peerRecordReads.length = 0;
+ const switchOutClick = switchingPage
+ .locator('[data-slot="shared-record-banner-exit"]')
+ .click({ noWaitAfter: true });
+
+ await expect(
+ peerPage.locator('[data-slot="record-scope-hydration-gate"]'),
+ ).toBeVisible();
+ await expect(
+ peerPage.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
+ await peerPage.waitForTimeout(200);
+ expect(peerRecordReads).toEqual([]);
+
+ switchOut.release();
+ await switchOutClick;
+ await expect(
+ switchingPage.getByRole("button", { name: "User menu" }),
+ ).toBeVisible();
+ await expect(
+ peerPage.getByRole("button", { name: "User menu" }),
+ ).toBeVisible();
+ } finally {
+ await context.close();
+ }
+ });
+});
diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
index 749644e6c..44cee5e3d 100644
--- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
+++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx
@@ -55,6 +55,9 @@ const mockAuthLoadingRef = { value: false };
const mockAccountAccessStatusRef = {
value: "valid" as "absent" | "valid" | "invalid",
};
+const mockRecordSessionPhaseRef = {
+ value: "ready" as "ready" | "blocking" | "resolving",
+};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -76,6 +79,14 @@ vi.mock("@/hooks/use-auth", () => ({
clearCachesForSessionEnd: vi.fn(),
}));
+vi.mock("@/hooks/use-record-session-transition", () => ({
+ useRecordSessionTransition: () => ({
+ id: "cross-tab-switch",
+ phase: mockRecordSessionPhaseRef.value,
+ expectedScope: "record-a",
+ }),
+}));
+
const mockPathRef = { value: "/" };
vi.mock("next/navigation", () => ({
@@ -192,6 +203,21 @@ describe(" — the owner-only doors", () => {
}
});
+ it("holds protected children and owner-only doors while a peer tab changes the session", () => {
+ mockRecordSessionPhaseRef.value = "blocking";
+ try {
+ const html = render(OWN_RECORD, "/labs");
+
+ expect(html).toContain('data-slot="record-scope-hydration-gate"');
+ expect(html).not.toContain("sentinel-page");
+ for (const slot of DOORS) {
+ expect(html).not.toContain(slot);
+ }
+ } finally {
+ mockRecordSessionPhaseRef.value = "ready";
+ }
+ });
+
it("does not delay public routes for record capability resolution", () => {
mockAuthLoadingRef.value = true;
try {
diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx
index f913d29e3..968e7733c 100644
--- a/src/components/layout/auth-shell.tsx
+++ b/src/components/layout/auth-shell.tsx
@@ -11,6 +11,7 @@ import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
+import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { isDestinationInSharedRecord } from "./nav-model";
@@ -58,6 +59,7 @@ export function AuthShell({
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
+ const recordSessionTransition = useRecordSessionTransition();
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
@@ -79,8 +81,13 @@ export function AuthShell({
// decides what to mount and the surfaces beneath it decide what to offer,
// and the two disagreeing is the whole class of defect this hook exists to
// close.
- const { inSharedRecord, recordKind, sections, accessRefused } =
- useRecordCapabilities();
+ const {
+ inSharedRecord,
+ recordKind,
+ sections,
+ accessRefused,
+ recordSessionPending,
+ } = useRecordCapabilities();
// A managed profile has a deliberately small Settings surface of its own.
// Let that route family reach its record-aware gate; adult shared records
// stay in the generic refusal branch even at MANAGE.
@@ -231,7 +238,11 @@ export function AuthShell({
// Admin. The payload resolves the active record as well as identity, so a
// child mounted before it could issue an actor-scoped read before the shell
// knows that a switched record must be refused or scoped differently.
- if (isLoading) {
+ if (
+ isLoading ||
+ recordSessionTransition.phase !== "ready" ||
+ recordSessionPending
+ ) {
return ;
}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 39407f5a6..4d9808836 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -23,8 +23,16 @@ import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
+import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
-import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
+import {
+ discardDashboardSnapshotPreload,
+ prefetchDashboardSnapshot,
+} from "@/lib/queries/use-dashboard-snapshot";
+import {
+ getRecordSessionTransition,
+ subscribeToRecordSessionTransition,
+} from "@/lib/query-keys/record-session-transition";
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
import {
getRecordScope,
@@ -208,16 +216,19 @@ function DashboardSnapshotPreloader() {
const queryClient = useQueryClient();
const pathname = usePathname();
const { user, isLoading } = useAuth();
+ const transition = useRecordSessionTransition();
const recordScope = useSyncExternalStore(
subscribeToClientRecordScope,
getClientRecordScope,
getServerRecordScope,
);
- const ready = isRecordPreloadReady({
- isLoading,
- accountAccessStatus: user?.accountAccessStatus,
- activeAccountId: user?.accountAccess?.active?.accountId ?? null,
- });
+ const ready =
+ transition.phase === "ready" &&
+ isRecordPreloadReady({
+ isLoading,
+ accountAccessStatus: user?.accountAccessStatus,
+ activeAccountId: user?.accountAccess?.active?.accountId ?? null,
+ });
useEffect(() => {
if (!ready) return;
@@ -237,7 +248,7 @@ function DashboardSnapshotPreloader() {
// A transition to an unresolved, refused, or different record scope must
// abort a preloader before its response can be adopted by the next route.
return () => controller.abort();
- }, [pathname, queryClient, ready, recordScope]);
+ }, [pathname, queryClient, ready, recordScope, transition.phase]);
return null;
}
@@ -245,9 +256,13 @@ function DashboardSnapshotPreloader() {
function QueryVisibilityRefreshBridge() {
const queryClient = useQueryClient();
+ const transition = useRecordSessionTransition();
useEffect(
- () => subscribeToMeaningfulVisibilityRefresh(queryClient),
- [queryClient],
+ () =>
+ transition.phase === "ready"
+ ? subscribeToMeaningfulVisibilityRefresh(queryClient)
+ : undefined,
+ [queryClient, transition.phase],
);
return null;
}
@@ -261,10 +276,16 @@ function QueryVisibilityRefreshBridge() {
// empty skeletons. Build-version + age gated; cleared on logout.
function QueryPersistenceBridge() {
const queryClient = useQueryClient();
+ const transition = useRecordSessionTransition();
useEffect(() => {
+ if (transition.phase !== "ready") return;
let stop: (() => void) | undefined;
let cancelled = false;
- void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => {
+ void restorePersistedQueryCache(
+ queryClient,
+ SHELL_VERSION,
+ () => !cancelled,
+ ).finally(() => {
if (!cancelled) {
stop = startPersistingQueryCache(queryClient, SHELL_VERSION);
}
@@ -273,7 +294,42 @@ function QueryPersistenceBridge() {
cancelled = true;
stop?.();
};
+ }, [queryClient, transition.phase]);
+ return null;
+}
+
+/**
+ * Clear tab-local reads immediately whenever the browser-wide transition
+ * store enters its hold. The AuthShell consumes the same store synchronously,
+ * so protected children unmount while this bridge cancels their observers.
+ */
+function RecordSessionTransitionBridge() {
+ const queryClient = useQueryClient();
+ const { refetch } = useAuth();
+ const transition = useRecordSessionTransition();
+
+ useEffect(() => {
+ const clearForTransition = () => {
+ if (getRecordSessionTransition().phase === "ready") return;
+ discardDashboardSnapshotPreload();
+ void queryClient.cancelQueries();
+ queryClient.clear();
+ };
+ // A tab opened while another tab is already switching needs the same
+ // cache purge even though it has no future broadcast to receive.
+ clearForTransition();
+ return subscribeToRecordSessionTransition(clearForTransition);
}, [queryClient]);
+
+ useEffect(() => {
+ if (
+ transition.phase === "resolving" ||
+ (transition.phase === "ready" && transition.id !== null)
+ ) {
+ void refetch();
+ }
+ }, [refetch, transition.id, transition.phase]);
+
return null;
}
@@ -329,6 +385,7 @@ export function Providers({
+
diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts
index 25f00a232..1d53ee054 100644
--- a/src/hooks/use-account-switch.ts
+++ b/src/hooks/use-account-switch.ts
@@ -3,20 +3,22 @@
/**
* v1.36.0 — entering and leaving somebody else's record, from the browser.
*
- * The switch is three acts in a fixed order, and the order is the whole
- * safety argument:
+ * The switch is four acts in a fixed browser-coordination order:
*
- * 1. **Ask the server.** `POST /api/account/switch` validates the grant and
- * stamps the session. Nothing local changes until it answers, so a switch
- * the server refuses leaves the browser exactly where it was.
- * 2. **Empty every cache that survives a reload.** The IndexedDB query
+ * 1. **Hold every tab.** Publish a cross-tab `blocking` transition and clear
+ * local reads before the POST can change the shared server session.
+ * 2. **Ask the server.** `POST /api/account/switch` validates the grant and
+ * stamps the session. Any client-visible failure stays held until a
+ * fresh `/api/auth/me` resolves the record the server actually kept; it
+ * never restores stale caches.
+ * 3. **Empty every cache that survives a reload.** The IndexedDB query
* snapshot and the service worker's `healthlog-data-*` cache both answer
* reads from disk. Left in place, the first paint on the other side can
* be the PREVIOUS record's numbers under a banner naming the new one —
* and nothing in the page's own code would be wrong, because the answer
* would not have come from the page. Awaited, not fired: a wipe racing
* the navigation is a wipe that sometimes does not happen.
- * 3. **Reload the document.** Not `router.refresh()`, not a query
+ * 4. **Reload the document.** Not `router.refresh()`, not a query
* invalidation. A hard navigation is what discards the in-memory cache,
* every React state tree holding a rendered value, and every in-flight
* request that was issued against the old record. The reload IS the cache
@@ -38,7 +40,11 @@ import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
import { queryKeys } from "@/lib/query-keys";
-import { setRecordScope } from "@/lib/query-keys/record-scope";
+import {
+ beginRecordSessionTransition,
+ commitRecordSessionTransition,
+ resolveUnknownRecordSessionTransition,
+} from "@/lib/query-keys/record-session-transition";
/**
* The two stable sharing error codes, as the envelope publishes them
@@ -96,17 +102,13 @@ async function postSwitch(accountId: string | null): Promise {
}
/**
- * Complete the switch: stamp the cache scope, empty the disk layers, reload.
+ * Complete a server-confirmed switch: empty the disk layers, reload.
*
* Extracted from the mutation so the grant-loss reset can reuse the exact same
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
* whose order is the safety property.
*/
-async function landOnRecord(
- accountId: string | null,
- destination = "/",
-): Promise {
- setRecordScope(accountId);
+async function landOnRecord(destination = "/"): Promise {
await clearOfflineCachesForRecordSwitch();
window.location.assign(destination);
// The document is being replaced. Nothing after this runs, and the promise
@@ -136,12 +138,26 @@ export function useAccountSwitch() {
accountId: string | null;
destination: string;
}) => {
- await postSwitch(accountId);
+ // Publish the browser-wide hold before the server sees the session
+ // change. Every recipient blocks its shell and clears local reads before
+ // it reconciles the committed scope through a fresh `/me`.
+ const transitionId = beginRecordSessionTransition(accountId);
// The in-memory cache holds the record we are leaving. Clearing it here
// as well as reloading costs nothing and closes the window in which a
// failed navigation would leave the old entries reachable.
+ void queryClient.cancelQueries();
queryClient.clear();
- return landOnRecord(accountId, destination);
+ try {
+ await postSwitch(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
+ // tabs held until a fresh `/me` resolves the record the server kept.
+ resolveUnknownRecordSessionTransition(transitionId);
+ throw error;
+ }
+ commitRecordSessionTransition(transitionId, accountId);
+ return landOnRecord(destination);
},
onError: (error: unknown) => {
toast.error(
@@ -168,14 +184,15 @@ export function useAccountSwitch() {
*
* The switcher-reset the contract asks for: on `sharing.access.denied` the
* session is still stamped with an account it may no longer act on, and every
- * delegable read from here on is a 403. Clearing the stamp puts the browser
- * back in its own account rather than leaving somebody staring at a wall of
- * refusals under a banner that says they are somewhere they are not.
+ * delegable read from here on is a 403. When the server confirms the reset,
+ * its own-record scope replaces the stamp rather than leaving somebody
+ * staring at refusals under a banner that says they are somewhere they are
+ * not.
*
* `POST /api/account/switch` with `null` is an actor surface and always
* succeeds — it is the way out, by design. If even that fails (offline, server
- * down) the reload still happens: the stamp survives, but so does the honest
- * refusal, and the alternative is a page that neither recovers nor says why.
+ * down), the browser keeps the transition unresolved and reloads. A fresh
+ * `/api/auth/me`, not a local scope guess, decides what may render next.
*/
/**
* Watch the cache for the moment the server stops honouring the switch.
@@ -204,12 +221,20 @@ export function subscribeToGrantLoss(
}
export function useLeaveSharedRecordOnGrantLoss() {
+ const queryClient = useQueryClient();
return useCallback(async () => {
+ const transitionId = beginRecordSessionTransition(null);
+ void queryClient.cancelQueries();
+ queryClient.clear();
try {
await postSwitch(null);
+ commitRecordSessionTransition(transitionId, null);
} catch {
- /* the reload below is the part that matters */
+ // The grant-loss path deliberately still reloads, but it cannot claim
+ // an own-record scope when the POST outcome is unknown. The next `/me`
+ // is the only authority that may release this resolving hold.
+ resolveUnknownRecordSessionTransition(transitionId);
}
- await landOnRecord(null);
- }, []);
+ await landOnRecord();
+ }, [queryClient]);
}
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 453476e0d..01ff12b53 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -27,6 +27,10 @@ import {
setRecordScope,
setRefusedRecordScope,
} from "@/lib/query-keys/record-scope";
+import {
+ settleRecordSessionTransition,
+ settleRefusedRecordSessionTransition,
+} from "@/lib/query-keys/record-session-transition";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
@@ -320,6 +324,15 @@ export async function fetchMe(): Promise {
} else {
setRecordScope(accountAccess.active?.accountId ?? null);
}
+ // A cross-tab switch holds the shell until this server-resolved payload
+ // confirms the record the transition committed. The malformed branch has
+ // already installed its refusal sentinel, so it may release only into the
+ // refusal door rather than being mistaken for the caller's own record.
+ if (accountAccessStatus === "invalid") {
+ settleRefusedRecordSessionTransition();
+ } else {
+ settleRecordSessionTransition(accountAccess.active?.accountId ?? null);
+ }
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts
index 8c536b850..6e8bf8c38 100644
--- a/src/hooks/use-record-capabilities.ts
+++ b/src/hooks/use-record-capabilities.ts
@@ -1,6 +1,7 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
+import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import {
accountLabel,
type AccountAccessEntry,
@@ -71,6 +72,8 @@ import {
* search a button in gets a sentence.
*/
export interface RecordCapabilities {
+ /** A tab is awaiting a fresh `/me` after another tab changed the session. */
+ recordSessionPending?: boolean;
/** A present account-access block was malformed and must not mount a record. */
accessRefused?: boolean;
/** Is this browser acting on somebody else's record right now. */
@@ -114,10 +117,12 @@ export interface RecordCapabilities {
export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
accessRefused = false,
+ recordSessionPending = false,
): RecordCapabilities {
- if (accessRefused) {
+ if (accessRefused || recordSessionPending) {
return {
- accessRefused: true,
+ accessRefused: accessRefused || undefined,
+ recordSessionPending: recordSessionPending || undefined,
inSharedRecord: true,
canWrite: false,
canAdd: false,
@@ -171,9 +176,11 @@ export function resolveRecordCapabilities(
*/
export function useRecordCapabilities(): RecordCapabilities {
const { user } = useAuth();
+ const transition = useRecordSessionTransition();
return resolveRecordCapabilities(
user?.accountAccess?.active,
user?.accountAccessStatus === "invalid",
+ transition.phase !== "ready",
);
}
diff --git a/src/hooks/use-record-session-transition.ts b/src/hooks/use-record-session-transition.ts
new file mode 100644
index 000000000..9061aaf63
--- /dev/null
+++ b/src/hooks/use-record-session-transition.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import {
+ getRecordSessionTransition,
+ subscribeToRecordSessionTransition,
+ type RecordSessionTransition,
+} from "@/lib/query-keys/record-session-transition";
+
+const SERVER_TRANSITION: RecordSessionTransition = {
+ id: null,
+ phase: "ready",
+ expectedScope: undefined,
+};
+
+function getServerRecordSessionTransition(): RecordSessionTransition {
+ return SERVER_TRANSITION;
+}
+
+/** The tab-local view of a browser-wide record-session transition. */
+export function useRecordSessionTransition(): RecordSessionTransition {
+ return useSyncExternalStore(
+ subscribeToRecordSessionTransition,
+ getRecordSessionTransition,
+ getServerRecordSessionTransition,
+ );
+}
diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts
index 7fbd5cdd3..cb976125d 100644
--- a/src/lib/pwa/query-persister.ts
+++ b/src/lib/pwa/query-persister.ts
@@ -172,6 +172,7 @@ function currentUserId(queryClient: QueryClient): string | null {
export async function restorePersistedQueryCache(
queryClient: QueryClient,
buildVersion: string,
+ canHydrate: () => boolean = () => true,
): Promise {
if (typeof indexedDB === "undefined") return;
// v1.36.0 — a browser inside somebody else's record restores nothing. The
@@ -219,7 +220,7 @@ export async function restorePersistedQueryCache(
(q) => cache.get(q.queryHash) === undefined,
),
};
- if (filtered.queries.length === 0) return;
+ if (filtered.queries.length === 0 || !canHydrate()) return;
hydrate(queryClient, filtered);
// Restored queries carry their original `dataUpdatedAt`, so with the
diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts
index 30b476ed8..c93346324 100644
--- a/src/lib/queries/use-dashboard-snapshot.ts
+++ b/src/lib/queries/use-dashboard-snapshot.ts
@@ -78,6 +78,11 @@ export function _resetDashboardSnapshotPreloadForTests(): void {
preloadedSnapshot = null;
}
+/** Drop a handoff when a tab begins or receives a record-session switch. */
+export function discardDashboardSnapshotPreload(): void {
+ preloadedSnapshot = null;
+}
+
/**
* Kick the snapshot fetch off ahead of the dashboard page chunk.
*
diff --git a/src/lib/query-keys/__tests__/record-session-transition.test.ts b/src/lib/query-keys/__tests__/record-session-transition.test.ts
new file mode 100644
index 000000000..5d26beb41
--- /dev/null
+++ b/src/lib/query-keys/__tests__/record-session-transition.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it } from "vitest";
+
+import {
+ __resetRecordSessionTransitionForTests,
+ beginRecordSessionTransition,
+ commitRecordSessionTransition,
+ getRecordSessionTransition,
+ resolveUnknownRecordSessionTransition,
+ settleRefusedRecordSessionTransition,
+ settleRecordSessionTransition,
+} from "@/lib/query-keys/record-session-transition";
+import {
+ __resetRecordScopeForTests,
+ getRecordScope,
+ setRecordScope,
+} from "@/lib/query-keys/record-scope";
+
+beforeEach(() => {
+ __resetRecordScopeForTests();
+ __resetRecordSessionTransitionForTests();
+});
+
+describe("record session transition", () => {
+ it("blocks every tab before the server switch starts", () => {
+ setRecordScope(null);
+
+ const transitionId = beginRecordSessionTransition("shared-record");
+
+ expect(getRecordSessionTransition()).toMatchObject({
+ id: transitionId,
+ phase: "blocking",
+ expectedScope: "shared-record",
+ });
+ expect(getRecordScope()).toBeNull();
+ });
+
+ it("keeps the hold through the committed scope until /me confirms it", () => {
+ const transitionId = beginRecordSessionTransition("shared-record");
+
+ commitRecordSessionTransition(transitionId, "shared-record");
+
+ expect(getRecordScope()).toBe("shared-record");
+ expect(getRecordSessionTransition()).toMatchObject({
+ id: transitionId,
+ phase: "resolving",
+ expectedScope: "shared-record",
+ });
+
+ settleRecordSessionTransition("shared-record");
+
+ expect(getRecordSessionTransition().phase).toBe("ready");
+ });
+
+ it("keeps a failed switch held until /me reconciles the prior scope", () => {
+ setRecordScope(null);
+ const transitionId = beginRecordSessionTransition("shared-record");
+
+ resolveUnknownRecordSessionTransition(transitionId);
+
+ expect(getRecordSessionTransition()).toMatchObject({
+ id: transitionId,
+ phase: "resolving",
+ expectedScope: undefined,
+ });
+ expect(getRecordScope()).toBeNull();
+
+ settleRecordSessionTransition(null);
+
+ expect(getRecordSessionTransition().phase).toBe("ready");
+ });
+
+ it("does not release a committed transition for a divergent /me response", () => {
+ const transitionId = beginRecordSessionTransition("shared-record");
+ commitRecordSessionTransition(transitionId, "shared-record");
+
+ settleRecordSessionTransition("different-record");
+
+ expect(getRecordSessionTransition()).toMatchObject({
+ phase: "resolving",
+ expectedScope: "shared-record",
+ });
+ });
+
+ it("releases an explicitly refused /me response without trusting a scope", () => {
+ const transitionId = beginRecordSessionTransition("shared-record");
+ commitRecordSessionTransition(transitionId, "shared-record");
+
+ settleRefusedRecordSessionTransition();
+
+ expect(getRecordSessionTransition()).toEqual({
+ id: transitionId,
+ phase: "ready",
+ expectedScope: undefined,
+ });
+ });
+});
diff --git a/src/lib/query-keys/record-scope.ts b/src/lib/query-keys/record-scope.ts
index 35c5d74b6..9d08ea2ca 100644
--- a/src/lib/query-keys/record-scope.ts
+++ b/src/lib/query-keys/record-scope.ts
@@ -51,7 +51,7 @@
*/
import type { QueryKey } from "@tanstack/react-query";
-const STORAGE_KEY = "healthlog-record-scope";
+export const RECORD_SCOPE_STORAGE_KEY = "healthlog-record-scope";
const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
/**
@@ -68,7 +68,7 @@ function notifyScopeListeners(): void {
function readMirror(): string | null {
try {
- const raw = localStorage.getItem(STORAGE_KEY);
+ const raw = localStorage.getItem(RECORD_SCOPE_STORAGE_KEY);
return raw === null || raw.length === 0 ? null : raw;
} catch {
// Private mode, disabled storage, or SSR. "Own record" is the safe
@@ -110,9 +110,9 @@ export function setRecordScope(accountId: string | null): void {
if (typeof window === "undefined") return;
try {
if (accountId === null) {
- localStorage.removeItem(STORAGE_KEY);
+ localStorage.removeItem(RECORD_SCOPE_STORAGE_KEY);
} else {
- localStorage.setItem(STORAGE_KEY, accountId);
+ localStorage.setItem(RECORD_SCOPE_STORAGE_KEY, accountId);
}
} catch {
/* storage unavailable — the in-process copy still partitions this tab */
@@ -132,7 +132,7 @@ export function subscribeToRecordScope(listener: () => void): () => void {
return () => scopeListeners.delete(listener);
}
const onStorage = (event: StorageEvent) => {
- if (event.key !== STORAGE_KEY) return;
+ if (event.key !== RECORD_SCOPE_STORAGE_KEY) return;
scope =
event.newValue === null || event.newValue.length === 0
? null
diff --git a/src/lib/query-keys/record-session-transition.ts b/src/lib/query-keys/record-session-transition.ts
new file mode 100644
index 000000000..e3db5ec33
--- /dev/null
+++ b/src/lib/query-keys/record-session-transition.ts
@@ -0,0 +1,282 @@
+"use client";
+
+import {
+ RECORD_SCOPE_STORAGE_KEY,
+ setRecordScope,
+} from "@/lib/query-keys/record-scope";
+
+/**
+ * A browser-wide hold around a server-side record-session switch.
+ *
+ * The session stamp is shared by every tab, but React Query and `/api/auth/me`
+ * are tab-local. A peer with a five-minute fresh `/me` cache can otherwise
+ * keep owner chrome mounted after another tab changes the server session. The
+ * switcher therefore publishes `blocking` before its POST. Recipients clear
+ * their local reads and render only the hydration gate. A committed scope then
+ * moves every tab to `resolving`; only a fresh `/me` whose active record
+ * exactly matches the committed scope releases the hold.
+ *
+ * This is a browser-side coordination point, not a distributed-security
+ * fence: BroadcastChannel and storage delivery cannot pre-empt a request that
+ * a suspended peer already sent. The server remains authoritative for every
+ * such request. After a tab processes the hold, this protocol removes local
+ * results and prevents its protected shell from issuing new reads until `/me`
+ * resolves the scope.
+ */
+export type RecordSessionPhase = "ready" | "blocking" | "resolving";
+
+export interface RecordSessionTransition {
+ id: string | null;
+ phase: RecordSessionPhase;
+ /** Undefined only for a recovery reload whose server outcome is unknown. */
+ expectedScope: string | null | undefined;
+}
+
+interface WireTransition {
+ id: string;
+ phase: Exclude | "ready";
+ expectedScope?: string | null;
+ at: number;
+}
+
+const STORAGE_KEY = "healthlog-record-session-transition";
+const CHANNEL_NAME = "healthlog-record-session-transition";
+const TRANSITION_MAX_AGE_MS = 30_000;
+const READY: RecordSessionTransition = {
+ id: null,
+ phase: "ready",
+ expectedScope: undefined,
+};
+
+let transition = READY;
+let seeded = false;
+const listeners = new Set<() => void>();
+let channel: BroadcastChannel | null = null;
+let storageListenerAttached = false;
+
+function notify(): void {
+ for (const listener of listeners) listener();
+}
+
+function makeId(): string {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return crypto.randomUUID();
+ }
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+function parseWire(value: string | null): WireTransition | null {
+ if (value === null) return null;
+ try {
+ const parsed = JSON.parse(value) as Partial;
+ if (
+ typeof parsed.id !== "string" ||
+ (parsed.phase !== "blocking" &&
+ parsed.phase !== "resolving" &&
+ parsed.phase !== "ready") ||
+ typeof parsed.at !== "number" ||
+ Date.now() - parsed.at > TRANSITION_MAX_AGE_MS
+ ) {
+ return null;
+ }
+ if (
+ parsed.expectedScope !== undefined &&
+ parsed.expectedScope !== null &&
+ typeof parsed.expectedScope !== "string"
+ ) {
+ return null;
+ }
+ return parsed as WireTransition;
+ } catch {
+ return null;
+ }
+}
+
+function toSnapshot(next: WireTransition): RecordSessionTransition {
+ return {
+ id: next.id,
+ // A new tab cannot know whether an inherited blocking POST is still in
+ // flight. It resolves `/me` rather than leaving the browser wedged if the
+ // initiating tab crashed between its POST and terminal message.
+ phase: next.phase === "blocking" && !seeded ? "resolving" : next.phase,
+ expectedScope: next.expectedScope,
+ };
+}
+
+function applyWire(next: WireTransition): void {
+ if (
+ transition.id === next.id &&
+ transition.phase === next.phase &&
+ transition.expectedScope === next.expectedScope
+ ) {
+ return;
+ }
+ transition = toSnapshot(next);
+ seeded = true;
+ notify();
+}
+
+function writeWire(next: WireTransition): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ /* storage unavailable — BroadcastChannel can still notify peers */
+ }
+}
+
+function postWire(next: WireTransition): void {
+ try {
+ channel?.postMessage(next);
+ } catch {
+ /* a closed channel is only an optimisation loss; storage remains */
+ }
+}
+
+function publish(next: WireTransition): void {
+ applyWire(next);
+ writeWire(next);
+ postWire(next);
+}
+
+function ensureTransport(): void {
+ if (typeof window === "undefined") return;
+ if (!storageListenerAttached) {
+ window.addEventListener("storage", (event: StorageEvent) => {
+ if (event.key === STORAGE_KEY) {
+ const next = parseWire(event.newValue);
+ if (next) applyWire(next);
+ return;
+ }
+ // A scope write without a transition marker is an external/legacy
+ // switch. Fail closed and force `/me` to reconcile it before any stale
+ // shell can issue a new record read.
+ if (event.key === RECORD_SCOPE_STORAGE_KEY) {
+ const expectedScope =
+ event.newValue === null || event.newValue.length === 0
+ ? null
+ : event.newValue;
+ // A current protocol transition already names this scope. The scope
+ // mirror is its second storage write, not a separate legacy switch.
+ if (
+ transition.phase !== "ready" &&
+ transition.expectedScope === expectedScope
+ ) {
+ return;
+ }
+ publish({
+ id: makeId(),
+ phase: "resolving",
+ expectedScope,
+ at: Date.now(),
+ });
+ }
+ });
+ storageListenerAttached = true;
+ }
+ if (channel === null && typeof BroadcastChannel !== "undefined") {
+ channel = new BroadcastChannel(CHANNEL_NAME);
+ channel.onmessage = (event: MessageEvent) => {
+ const value = event.data;
+ if (value === null || typeof value !== "object") return;
+ const next = parseWire(JSON.stringify(value));
+ if (next) applyWire(next);
+ };
+ }
+}
+
+function seed(): void {
+ if (seeded) return;
+ if (typeof window === "undefined") {
+ seeded = true;
+ return;
+ }
+ const next = parseWire(localStorage.getItem(STORAGE_KEY));
+ if (next) {
+ transition = toSnapshot(next);
+ }
+ seeded = true;
+}
+
+export function getRecordSessionTransition(): RecordSessionTransition {
+ seed();
+ return transition;
+}
+
+export function subscribeToRecordSessionTransition(
+ listener: () => void,
+): () => void {
+ seed();
+ ensureTransport();
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/** Publish the safe hold before issuing `POST /api/account/switch`. */
+export function beginRecordSessionTransition(
+ expectedScope: string | null,
+): string {
+ seed();
+ ensureTransport();
+ const id = makeId();
+ publish({ id, phase: "blocking", expectedScope, at: Date.now() });
+ return id;
+}
+
+/** Record the server-confirmed scope, while every tab still waits for `/me`. */
+export function commitRecordSessionTransition(
+ id: string,
+ expectedScope: string | null,
+): void {
+ if (transition.id !== id) return;
+ publish({ id, phase: "resolving", expectedScope, at: Date.now() });
+ // The terminal transition marker is deliberately published before the
+ // scope mirror. A peer that receives the two storage events separately is
+ // already held when it learns the new record hash.
+ setRecordScope(expectedScope);
+}
+
+/**
+ * A switch response was lost after the request was made. Keep all tabs held
+ * and let `/me` decide which record the server actually kept.
+ */
+export function resolveUnknownRecordSessionTransition(id: string): void {
+ if (transition.id !== id) return;
+ publish({ id, phase: "resolving", at: Date.now() });
+}
+
+/** Release only the `/me` response that confirms the committed record. */
+export function settleRecordSessionTransition(
+ resolvedScope: string | null,
+): void {
+ if (transition.phase !== "resolving") return;
+ if (
+ transition.expectedScope !== undefined &&
+ transition.expectedScope !== resolvedScope
+ ) {
+ return;
+ }
+ const id = transition.id ?? makeId();
+ publish({ id, phase: "ready", at: Date.now() });
+}
+
+/**
+ * Release the browser hold after a server response has explicitly refused its
+ * access block. The caller has already installed the refused scope sentinel,
+ * so returning to `ready` can only render the access-refusal door; it never
+ * restores a prior record scope.
+ */
+export function settleRefusedRecordSessionTransition(): void {
+ if (transition.phase !== "resolving") return;
+ const id = transition.id ?? makeId();
+ publish({ id, phase: "ready", at: Date.now() });
+}
+
+/** @internal — deterministic transition state for unit tests. */
+export function __resetRecordSessionTransitionForTests(): void {
+ transition = READY;
+ seeded = false;
+ listeners.clear();
+ channel?.close();
+ channel = null;
+}
From 5c9befc0ae2aab3e63839d6ab39fb9bf33633aa2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 17:52:10 +0200
Subject: [PATCH 37/54] feat(02-12.1): stamp a record-context epoch on every
session
- add sessions.record_epoch, owned by a BEFORE UPDATE trigger so the
ON DELETE SET NULL path and raw SQL writes cannot skip it
- normalise pre-existing rows so epoch zero means never switched
- project the epoch off the row getSession already loads
---
.../0302_session_record_epoch/migration.sql | 64 +++++
prisma/schema.prisma | 18 ++
.../insights/targets/__tests__/route.test.ts | 1 +
.../api/mcp/oauth/__tests__/authorize.test.ts | 1 +
.../__tests__/acting-account-resolver.test.ts | 12 +-
src/lib/auth/session.ts | 16 +-
.../record-session-epoch-trigger.test.ts | 266 ++++++++++++++++++
7 files changed, 376 insertions(+), 2 deletions(-)
create mode 100644 prisma/migrations/0302_session_record_epoch/migration.sql
create mode 100644 tests/integration/record-session-epoch-trigger.test.ts
diff --git a/prisma/migrations/0302_session_record_epoch/migration.sql b/prisma/migrations/0302_session_record_epoch/migration.sql
new file mode 100644
index 000000000..322c55fea
--- /dev/null
+++ b/prisma/migrations/0302_session_record_epoch/migration.sql
@@ -0,0 +1,64 @@
+-- v1.37.0 — a monotonic counter for "how many times has this session's record
+-- selector moved".
+--
+-- The problem it solves is not authorization; `acting_as_user_id` plus the
+-- grant join already decides that. It is agreement. A browser holds several
+-- tabs on one session, and a request that left a suspended tab before a switch
+-- landed arrives after it — carrying an intent formed under the old record and
+-- addressed to a server that has since moved. The request is perfectly
+-- authorized and answers the wrong question. So every request that resolves a
+-- record now asserts which context it believes it is in, and the server refuses
+-- when that belief no longer matches the row.
+--
+-- The counter has to be the DATABASE's, not the application's, because the
+-- application is not the only writer of the column it tracks. Three of the
+-- five writers run no application code at all:
+--
+-- * `sessions_acting_as_user_id_fkey ON DELETE SET NULL` — deleting an owner
+-- account nulls every delegate session pointing at it, by referential
+-- action, with nothing to hook;
+-- * raw `UPDATE sessions SET acting_as_user_id = NULL` in the end-to-end
+-- fixtures;
+-- * an operator at `psql`, and whatever writes this column next year.
+--
+-- An application-level increment would be a discipline those three break
+-- silently, and a fence whose counter can silently fail to move is a fence that
+-- passes stale requests. A row trigger cannot be forgotten.
+--
+-- BEFORE, not AFTER, and that timing is load-bearing: the trigger overwrites
+-- whatever `record_epoch` the incoming statement supplied, so no writer can set
+-- the epoch itself even by trying. The counter belongs to the database in the
+-- strong sense.
+--
+-- `UPDATE OF "acting_as_user_id"` plus the `WHEN` clause keep the two writes
+-- that touch a session on nearly every request off the trigger entirely — the
+-- throttled `last_active_at` stamp and the sliding `expires_at` extension both
+-- name other columns, and neither would satisfy the `IS DISTINCT FROM` test
+-- even if it did.
+--
+-- Precedent for a trigger on this line: `0298_notification_delivery_identity`
+-- ships `push_attempt_attribution_guard` the same way. A trigger is invisible
+-- to Prisma's datamodel comparison, which 0298 already proved tolerable.
+
+ALTER TABLE "sessions" ADD COLUMN "record_epoch" INTEGER NOT NULL DEFAULT 0;
+
+-- A session already sitting inside somebody else's record is not a session
+-- that has never left its own, and the fence's exemption for pre-existing
+-- browser tabs is defined as epoch zero. Normalise before the trigger exists,
+-- so that from the first request after deploy `record_epoch = 0` means exactly
+-- "never pointed at another record, and not pointed at one now".
+UPDATE "sessions" SET "record_epoch" = 1 WHERE "acting_as_user_id" IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION "sessions_bump_record_epoch"() RETURNS trigger
+LANGUAGE plpgsql AS $$
+BEGIN
+ NEW."record_epoch" := OLD."record_epoch" + 1;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER "sessions_record_epoch_bump"
+ BEFORE UPDATE OF "acting_as_user_id" ON "sessions"
+ FOR EACH ROW
+ WHEN (OLD."acting_as_user_id" IS DISTINCT FROM NEW."acting_as_user_id")
+ EXECUTE FUNCTION "sessions_bump_record_epoch"();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 30290011e..9701fa010 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1179,6 +1179,24 @@ model Session {
/// into its own account.
actingAsUserId String? @map("acting_as_user_id")
+ /// v1.37.0 — how many times this session's record selector has moved.
+ ///
+ /// Monotonic, starts at 0, and NEVER written by application code: migration
+ /// 0302 installs a `BEFORE UPDATE OF acting_as_user_id` trigger that sets it,
+ /// so the increment also happens on the paths where no application code runs
+ /// at all — the `ON DELETE SET NULL` referential action above, a raw fixture
+ /// `UPDATE`, an operator at `psql`. A convention that every writer must
+ /// remember is a convention those three break silently.
+ ///
+ /// Consumer: the record-session fence
+ /// (`src/lib/sharing/record-session-fence.ts`), which refuses a cookie
+ /// request whose asserted epoch no longer matches the row. `0` additionally
+ /// means "this session has never been pointed at another record", which is
+ /// the fence's exemption for browser tabs that predate the feature; the
+ /// migration normalises existing rows so that invariant holds from the first
+ /// request after deploy.
+ recordEpoch Int @default(0) @map("record_epoch")
+
user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade)
actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull)
diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts
index 44c48d332..19b0ca3e4 100644
--- a/src/app/api/insights/targets/__tests__/route.test.ts
+++ b/src/app/api/insights/targets/__tests__/route.test.ts
@@ -100,6 +100,7 @@ const SESSION_OK = {
id: "sess-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId: null,
+ recordEpoch: 0,
},
user: SESSION_USER as never,
};
diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
index 5040f996e..9bcc403b3 100644
--- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts
+++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
@@ -81,6 +81,7 @@ function signedIn() {
id: "s1",
expiresAt: new Date(Date.now() + 1e6),
actingAsUserId: null,
+ recordEpoch: 0,
},
user: { id: "user-1" } as never,
});
diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts
index fac096103..2e6a4e045 100644
--- a/src/lib/__tests__/acting-account-resolver.test.ts
+++ b/src/lib/__tests__/acting-account-resolver.test.ts
@@ -148,16 +148,25 @@ function user(
};
}
-/** Sign the delegate in over the cookie transport. */
+/**
+ * Sign the delegate in over the cookie transport.
+ *
+ * `recordEpoch` defaults to 0, which is the record-session fence's exemption:
+ * a session that has never been pointed at another record is served without a
+ * fence header, so every pre-fence case in this file keeps meaning what it
+ * meant. Cases that want the fence live pass an epoch explicitly.
+ */
function signedInCookie(
actingAsUserId: string | null = null,
role: "USER" | "ADMIN" = "USER",
+ recordEpoch = 0,
): void {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId,
+ recordEpoch,
},
user: user(DELEGATE, role) as never,
});
@@ -649,6 +658,7 @@ describe("the cookie-only helpers stay out of reach of a switch", () => {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId: OWNER,
+ recordEpoch: 0,
},
user: {
...user(DELEGATE),
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index 74ef90227..d55186487 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -246,7 +246,12 @@ export async function createSession(
}
export async function getSession(): Promise<{
- session: { id: string; expiresAt: Date; actingAsUserId: string | null };
+ session: {
+ id: string;
+ expiresAt: Date;
+ actingAsUserId: string | null;
+ recordEpoch: number;
+ };
user: User;
} | null> {
try {
@@ -363,6 +368,15 @@ export async function getSession(): Promise<{
// `src/__tests__/acting-account-boundary-guard.test.ts` freezes the set
// of files allowed to touch this field.
actingAsUserId: session.actingAsUserId,
+ // v1.37.0 — the selector's move counter, projected off the same row for
+ // the same reason. It is what makes the fence's central property true:
+ // the epoch a request is validated against and the `actingAsUserId` the
+ // handler is about to serve under are one row read, so they cannot
+ // straddle a switch that landed between two queries.
+ //
+ // Written by a database trigger only (migration 0302); nothing in the
+ // application assigns it, and the `BEFORE` timing means nothing can.
+ recordEpoch: session.recordEpoch,
},
user: session.user,
};
diff --git a/tests/integration/record-session-epoch-trigger.test.ts b/tests/integration/record-session-epoch-trigger.test.ts
new file mode 100644
index 000000000..a3ec9cb09
--- /dev/null
+++ b/tests/integration/record-session-epoch-trigger.test.ts
@@ -0,0 +1,266 @@
+/**
+ * FENCE-AC-09 — the record-selector epoch, against real Postgres.
+ *
+ * `Session.recordEpoch` is the one fact the whole fence rests on, and it is
+ * owned by a database trigger rather than by application code. That choice only
+ * pays off if it is true for the writers that run no application code, so this
+ * file exercises those writers directly rather than through a helper:
+ *
+ * * the `ON DELETE SET NULL` referential action on `acting_as_user_id`,
+ * which fires when an owner account is deleted and executes nothing of ours;
+ * * a raw `UPDATE sessions SET acting_as_user_id = NULL`, the shape the
+ * end-to-end fixtures use;
+ * * a statement that tries to set `record_epoch` itself, which must lose.
+ *
+ * And the negative half, which matters just as much: the two writes that touch
+ * a session on nearly every request — the throttled `last_active_at` stamp and
+ * the sliding `expires_at` extension — must not move the counter. A counter
+ * that drifts on ordinary traffic would refuse every request in the app.
+ *
+ * Break this file by deleting the `CREATE TRIGGER` statement from migration
+ * 0302: the referential-action leg and the `clearActingSessions` leg both fail,
+ * because nothing else increments.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+import { clearActingSessions } from "@/lib/sharing/acting-session";
+
+let counter = 0;
+
+async function makeUser(label: string) {
+ const suffix = `${label}-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `epoch-${suffix}`,
+ email: `epoch-${suffix}@example.test`,
+ role: "USER",
+ },
+ });
+}
+
+async function makeSession(userId: string, actingAsUserId: string | null) {
+ return getPrismaClient().session.create({
+ data: {
+ userId,
+ actingAsUserId,
+ expiresAt: new Date(Date.now() + 60_000),
+ },
+ });
+}
+
+async function epochOf(sessionId: string): Promise {
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: sessionId },
+ });
+ return row.recordEpoch;
+}
+
+describe("FENCE-AC-09 record-selector epoch trigger", () => {
+ beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ });
+
+ it("FENCE-AC-09 starts every fresh session at zero", async () => {
+ const user = await makeUser("fresh");
+ const session = await makeSession(user.id, null);
+ expect(session.recordEpoch).toBe(0);
+ });
+
+ it("FENCE-AC-09 bumps by exactly one when the selector is pointed at a record", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+
+ expect(await epochOf(session.id)).toBe(1);
+ });
+
+ it("FENCE-AC-09 bumps again on the way back out", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, owner.id);
+ // Seeded already pointed at the owner: the INSERT does not fire an UPDATE
+ // trigger, so this row starts at 0 and the switch-out is its first bump.
+ expect(await epochOf(session.id)).toBe(0);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: null },
+ });
+
+ expect(await epochOf(session.id)).toBe(1);
+ });
+
+ it("FENCE-AC-09 does not bump when the selector is rewritten to the same value", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+ expect(await epochOf(session.id)).toBe(1);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+ expect(await epochOf(session.id)).toBe(1);
+
+ // And the NULL -> NULL case, which `IS DISTINCT FROM` has to get right
+ // where a bare `<>` would not.
+ const untouched = await makeSession(delegate.id, null);
+ await getPrismaClient().session.update({
+ where: { id: untouched.id },
+ data: { actingAsUserId: null },
+ });
+ expect(await epochOf(untouched.id)).toBe(0);
+ });
+
+ it("FENCE-AC-09 bumps on the ON DELETE SET NULL referential action, where no application code runs", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+ const beforeDelete = await epochOf(session.id);
+ expect(beforeDelete).toBe(1);
+
+ // Deleting the OWNER nulls the delegate's selector by referential action.
+ // Nothing of ours executes; the trigger is the only thing that can move
+ // the counter here.
+ await getPrismaClient().user.delete({ where: { id: owner.id } });
+
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: session.id },
+ });
+ expect(row.actingAsUserId).toBeNull();
+ expect(row.recordEpoch).toBe(beforeDelete + 1);
+ });
+
+ it("FENCE-AC-09 bumps every row clearActingSessions clears", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const first = await makeSession(delegate.id, null);
+ const second = await makeSession(delegate.id, null);
+ const unrelated = await makeSession(delegate.id, null);
+
+ for (const s of [first, second]) {
+ await getPrismaClient().session.update({
+ where: { id: s.id },
+ data: { actingAsUserId: owner.id },
+ });
+ }
+
+ const cleared = await clearActingSessions({
+ grantorId: owner.id,
+ granteeId: delegate.id,
+ });
+
+ expect(cleared).toBe(2);
+ expect(await epochOf(first.id)).toBe(2);
+ expect(await epochOf(second.id)).toBe(2);
+ // The session that was never inside the record is untouched, which is what
+ // keeps the fence's exemption meaningful.
+ expect(await epochOf(unrelated.id)).toBe(0);
+ });
+
+ it("FENCE-AC-09 bumps on a raw SQL clear, the shape the browser fixtures use", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+
+ await getPrismaClient()
+ .$executeRaw`UPDATE "sessions" SET "acting_as_user_id" = NULL WHERE "user_id" = ${delegate.id}`;
+
+ expect(await epochOf(session.id)).toBe(2);
+ });
+
+ it("FENCE-AC-09 leaves the per-request session writes alone", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { actingAsUserId: owner.id },
+ });
+ expect(await epochOf(session.id)).toBe(1);
+
+ // The throttled "last seen" stamp (`session.ts`).
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { lastActiveAt: new Date() },
+ });
+ // The sliding-expiry extension (`session.ts`).
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { expiresAt: new Date(Date.now() + 3_600_000) },
+ });
+ // And both at once, which is the shape a future consolidation would take.
+ await getPrismaClient().session.update({
+ where: { id: session.id },
+ data: { lastActiveAt: new Date(), expiresAt: new Date(Date.now() + 1) },
+ });
+
+ expect(await epochOf(session.id)).toBe(1);
+ });
+
+ it("FENCE-AC-09 refuses an application-supplied epoch: the trigger's value wins", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const session = await makeSession(delegate.id, null);
+
+ // A statement that sets both the selector and an arbitrary epoch. BEFORE
+ // timing means the trigger overwrites `NEW.record_epoch` wholesale, so a
+ // writer cannot steer the counter even deliberately.
+ await getPrismaClient()
+ .$executeRaw`UPDATE "sessions" SET "acting_as_user_id" = ${owner.id}, "record_epoch" = 999 WHERE "id" = ${session.id}`;
+
+ expect(await epochOf(session.id)).toBe(1);
+
+ // Nor can a writer freeze it by rewriting the current value.
+ await getPrismaClient()
+ .$executeRaw`UPDATE "sessions" SET "acting_as_user_id" = NULL, "record_epoch" = 1 WHERE "id" = ${session.id}`;
+
+ expect(await epochOf(session.id)).toBe(2);
+ });
+
+ it("FENCE-AC-09 holds the migration's normalisation invariant: epoch zero iff the selector is null", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ await makeSession(delegate.id, null);
+ const switched = await makeSession(delegate.id, null);
+ await getPrismaClient().session.update({
+ where: { id: switched.id },
+ data: { actingAsUserId: owner.id },
+ });
+
+ // The migration's `UPDATE ... SET record_epoch = 1 WHERE acting_as_user_id
+ // IS NOT NULL` establishes this over pre-existing rows; the trigger
+ // maintains one direction of it afterwards. What the fence actually relies
+ // on is the weaker, always-true half asserted here: a row still pointed at
+ // another record is never at epoch zero.
+ const pointed = await getPrismaClient().session.findMany({
+ where: { actingAsUserId: { not: null } },
+ });
+ expect(pointed.length).toBeGreaterThan(0);
+ for (const row of pointed) {
+ expect(row.recordEpoch).toBeGreaterThanOrEqual(1);
+ }
+ });
+});
From 59359738c84547d3188a23a44bfb67e345fbff32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 17:59:07 +0200
Subject: [PATCH 38/54] feat(02-12.1): decide record-session context in one
place
- add an import-free contract module both bundles can name, holding the
header names, the parse and the verdict
- add the server fence: header read, wide-event stamp, and the refusal
- add RecordSessionChangedError as a third sibling on the existing
sharing-error arm, so the 409 needs no new serialisation branch
---
.../acting-account-boundary-guard.test.ts | 14 +
src/lib/api-handler.ts | 44 ++
src/lib/logging/event-builder.ts | 19 +
src/lib/logging/types.ts | 12 +
.../__tests__/record-session-fence.test.ts | 439 ++++++++++++++++++
.../sharing/record-session-fence-contract.ts | 233 ++++++++++
src/lib/sharing/record-session-fence.ts | 134 ++++++
7 files changed, 895 insertions(+)
create mode 100644 src/lib/sharing/__tests__/record-session-fence.test.ts
create mode 100644 src/lib/sharing/record-session-fence-contract.ts
create mode 100644 src/lib/sharing/record-session-fence.ts
diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts
index df45e02db..42cf7af48 100644
--- a/src/__tests__/acting-account-boundary-guard.test.ts
+++ b/src/__tests__/acting-account-boundary-guard.test.ts
@@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => {
// whole auth context for exactly this reason: had it taken the id, the
// route would name the column too, and the list would grow per surface.
"lib/sharing/account-access.ts",
+ // v1.37.0 — the record-session fence COMPARES it and never writes it. It
+ // reads the value off the `AuthContext` the resolver already holds rather
+ // than from the row, which is the property the fence rests on: the scope
+ // the handler serves under and the epoch the fence validated are one row
+ // read, so they cannot straddle a switch. A version of this file that
+ // resolved its own session would be a second answer to "whose record is
+ // this" and would also be wrong more often.
+ "lib/sharing/record-session-fence.ts",
+ // v1.37.0 — names the column in prose only. The matcher does not exempt
+ // comments and is left that way on purpose (see the note above), so the
+ // module that explains the fence has to be listed. It imports nothing at
+ // all, which the fence's own unit test asserts, so it can read no column
+ // even in principle.
+ "lib/sharing/record-session-fence-contract.ts",
].sort();
it("no other file reads or writes the carrier column", () => {
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
index 92e0addb9..e434a4520 100644
--- a/src/lib/api-handler.ts
+++ b/src/lib/api-handler.ts
@@ -37,6 +37,8 @@ import {
type GrantNeed,
} from "./sharing/grants";
import type { ShareScope } from "./sharing/scope";
+import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract";
+import { assertRecordSessionFence } from "./sharing/record-session-fence";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
@@ -139,6 +141,38 @@ export class SharingNotPermittedError extends SharingAuthError {
}
}
+/**
+ * v1.37.0 — "the record this session is on is not the record you asserted."
+ *
+ * A third sibling rather than a branch, so it rides the existing
+ * `SharingAuthError` arm below with no new serialisation code. The status is
+ * the one thing that differs and it differs on purpose: 409, not 403, because
+ * this is not a refusal of access. The caller may well be entitled to both
+ * records. What failed is agreement about WHICH one, and the instruction the
+ * code carries is "reconcile through `/api/auth/me` and try again", not "you
+ * have lost this record".
+ *
+ * That distinction is load-bearing for deploy compatibility: the currently
+ * shipped bundle reacts to `sharing.access.denied` by leaving the record and
+ * hard-navigating, which is exactly the wrong response to a transient
+ * disagreement and exactly the right one for a bundle too old to reconcile. See
+ * `src/lib/sharing/record-session-fence-contract.ts` for why the fence hands
+ * each bundle the code it can act on.
+ *
+ * The body carries `meta.errorCode` and nothing else — no current epoch, no
+ * current scope. A client may learn its context from exactly two responses
+ * (`GET /api/auth/me` and `POST /api/account/switch`), and putting the truth in
+ * this refusal would open a third path, one that arrives on the very requests
+ * whose context is in doubt.
+ */
+export class RecordSessionChangedError extends SharingAuthError {
+ readonly errorCode = RECORD_FENCE_ERROR_CODE;
+ constructor() {
+ super(409, "The record this session is on has changed");
+ this.name = "RecordSessionChangedError";
+ }
+}
+
/**
* Wraps an API route handler with Wide Event logging.
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
@@ -393,6 +427,16 @@ export type AuthContext = {
* below reads it.
*/
readonly actingAsUserId?: string | null;
+ /**
+ * v1.37.0 — how many times this session's record selector has moved. The
+ * record-session fence compares it against what the request asserted.
+ *
+ * Optional for the same reason as `actingAsUserId` above, and `undefined`
+ * reads as `0` — which is the fence's exemption, so a hand-built test
+ * context keeps meaning "a session that never switched". Read only by the
+ * fence.
+ */
+ readonly recordEpoch?: number;
};
user: User;
/**
diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts
index c85f66ed9..7af2d2d77 100644
--- a/src/lib/logging/event-builder.ts
+++ b/src/lib/logging/event-builder.ts
@@ -118,6 +118,25 @@ export class WideEventBuilder {
return this;
}
+ /**
+ * v1.37.0 — record the record context this request resolved.
+ *
+ * Merges for the same reason `setActingAs` does. Written by exactly one
+ * caller, the record-session fence, and read back by `apiHandler` to attach
+ * the response echo. `apiHandler` deliberately does NOT derive the context
+ * itself: doing so would put a session read on every public route and make
+ * the echo a second derivation rather than a report of the one that was used.
+ */
+ setRecordContext(context: { epoch: number; scope: string | null }): this {
+ this.event.auth = { ...(this.event.auth ?? {}), record_context: context };
+ return this;
+ }
+
+ /** The record context the fence stamped, or undefined if it never ran. */
+ getRecordContext(): { epoch: number; scope: string | null } | undefined {
+ return this.event.auth?.record_context;
+ }
+
setProviderWorkAuthority(authority: ProviderWorkAuthority): this {
this.event.auth = {
...(this.event.auth ?? {}),
diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts
index 73d6ff47d..727f5440f 100644
--- a/src/lib/logging/types.ts
+++ b/src/lib/logging/types.ts
@@ -80,6 +80,18 @@ export interface WideEvent {
delegated_generation?: "suppressed";
/** Durable authority copied into provider queue payloads. */
provider_work_authority?: ProviderWorkAuthority;
+ /**
+ * v1.37.0 — the record context this request was actually served under, as
+ * the record-session fence read it off the session row.
+ *
+ * Stamped by the fence on every call it makes, pass or refuse, and by
+ * nothing else. `apiHandler` copies it onto two response headers so the
+ * browser can discard a response that resolved a different record than the
+ * one it is now showing. Present exactly on the responses that resolved a
+ * record scope; absent everywhere else, which is what makes "no echo" mean
+ * "nothing about a record was decided here" rather than "unknown".
+ */
+ record_context?: { epoch: number; scope: string | null };
};
// Business-Daten (was wurde getan)
diff --git a/src/lib/sharing/__tests__/record-session-fence.test.ts b/src/lib/sharing/__tests__/record-session-fence.test.ts
new file mode 100644
index 000000000..ed653122b
--- /dev/null
+++ b/src/lib/sharing/__tests__/record-session-fence.test.ts
@@ -0,0 +1,439 @@
+/**
+ * The record-session fence: every arm of the verdict, and the refusals it
+ * raises.
+ *
+ * The verdict is a pure function, so this file can be exhaustive about it in a
+ * way the integration proofs cannot. It also holds two structural assertions
+ * that belong nowhere else:
+ *
+ * * the contract module has no `import` statement at all — the property that
+ * lets the browser bundle name the same header constants without dragging
+ * `next/headers` or Prisma in behind them. It typechecks either way, so
+ * only a check like this one or a full `pnpm build` catches a regression;
+ * * the asserted-scope length bound equals `MAX_SELECTOR_LENGTH`. The
+ * contract module deliberately does not import that constant (importing
+ * `acting-carrier.ts` is the exact poisoning above), so nothing but an
+ * assertion keeps the two numbers together.
+ *
+ * Break this file by changing the `unfenced-client` arm of
+ * `recordSessionFenceVerdict` to return `"pass"`: every absent-header case on a
+ * fenced session fails.
+ */
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { MAX_SELECTOR_LENGTH } from "@/lib/auth/acting-carrier";
+import {
+ MAX_ASSERTED_SCOPE_LENGTH,
+ RECORD_EPOCH_HEADER,
+ RECORD_FENCE_BOOTSTRAP,
+ RECORD_FENCE_ERROR_CODE,
+ RECORD_SCOPE_HEADER,
+ RECORD_SCOPE_SELF,
+ parseAssertedContext,
+ recordScopeHeaderValue,
+ recordSessionFenceVerdict,
+ type AssertedRecordContext,
+ type RecordFenceVerdict,
+} from "@/lib/sharing/record-session-fence-contract";
+
+const headerJar = new Map();
+
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+}));
+
+const OWNER = "owner-account-id";
+const OTHER = "other-account-id";
+
+function asserted(epoch: number, scope: string | null): AssertedRecordContext {
+ return { kind: "asserted", epoch, scope };
+}
+
+describe("recordSessionFenceVerdict", () => {
+ type Case = {
+ name: string;
+ input: Parameters[0];
+ want: RecordFenceVerdict;
+ };
+
+ const CASES: Case[] = [
+ {
+ name: "bearer passes with no assertion at all",
+ input: {
+ transport: "bearer",
+ sessionEpoch: 0,
+ sessionScope: null,
+ asserted: { kind: "absent" },
+ },
+ want: "pass",
+ },
+ {
+ name: "bearer passes even against a nonsense assertion",
+ input: {
+ transport: "bearer",
+ sessionEpoch: 7,
+ sessionScope: OWNER,
+ asserted: asserted(1, OTHER),
+ },
+ want: "pass",
+ },
+ {
+ name: "a session that never switched passes with no header",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 0,
+ sessionScope: null,
+ asserted: { kind: "absent" },
+ },
+ want: "pass",
+ },
+ {
+ name: "a session that never switched passes on the bootstrap sentinel",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 0,
+ sessionScope: null,
+ asserted: { kind: "unparseable" },
+ },
+ want: "pass",
+ },
+ {
+ name: "a fenced session with a matching epoch and scope passes",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 3,
+ sessionScope: OWNER,
+ asserted: asserted(3, OWNER),
+ },
+ want: "pass",
+ },
+ {
+ name: "a fenced session back on its own record passes on a matching self assertion",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 4,
+ sessionScope: null,
+ asserted: asserted(4, null),
+ },
+ want: "pass",
+ },
+ {
+ name: "a stale epoch is stale",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 3,
+ sessionScope: OWNER,
+ asserted: asserted(2, OWNER),
+ },
+ want: "stale",
+ },
+ {
+ name: "an epoch from the future is stale",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 3,
+ sessionScope: OWNER,
+ asserted: asserted(4, OWNER),
+ },
+ want: "stale",
+ },
+ {
+ name: "a matching epoch with a mismatched scope is stale",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 3,
+ sessionScope: OWNER,
+ asserted: asserted(3, OTHER),
+ },
+ want: "stale",
+ },
+ {
+ name: "asserting a record while the session is on its own is stale",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 5,
+ sessionScope: null,
+ asserted: asserted(5, OWNER),
+ },
+ want: "stale",
+ },
+ {
+ name: "asserting self while the session is inside a record is stale",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 5,
+ sessionScope: OWNER,
+ asserted: asserted(5, null),
+ },
+ want: "stale",
+ },
+ {
+ name: "an unparseable assertion on a fenced session is stale, not unfenced",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 1,
+ sessionScope: OWNER,
+ asserted: { kind: "unparseable" },
+ },
+ want: "stale",
+ },
+ {
+ name: "an absent assertion on a fenced session is an unfenced client",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 1,
+ sessionScope: OWNER,
+ asserted: { kind: "absent" },
+ },
+ want: "unfenced-client",
+ },
+ {
+ name: "a session that has LEFT a record stays fenced",
+ input: {
+ transport: "cookie",
+ sessionEpoch: 2,
+ sessionScope: null,
+ asserted: { kind: "absent" },
+ },
+ want: "unfenced-client",
+ },
+ ];
+
+ it("covers a non-zero number of cases", () => {
+ // A table-driven test whose table emptied would report success.
+ expect(CASES.length).toBeGreaterThan(10);
+ });
+
+ for (const c of CASES) {
+ it(c.name, () => {
+ expect(recordSessionFenceVerdict(c.input)).toBe(c.want);
+ });
+ }
+
+ it("keeps the exemption's second clause honest", () => {
+ // Migration 0302 makes `scope !== null` imply `epoch >= 1`, so the second
+ // conjunct of the exemption is redundant against real data. It is kept
+ // because a redundant clause over an authorization boundary is the right
+ // direction to be wrong in — and this asserts it actually does something
+ // if the data ever disagrees with the migration.
+ expect(
+ recordSessionFenceVerdict({
+ transport: "cookie",
+ sessionEpoch: 0,
+ sessionScope: OWNER,
+ asserted: { kind: "absent" },
+ }),
+ ).toBe("unfenced-client");
+ });
+});
+
+describe("parseAssertedContext", () => {
+ it("reads no headers as absent", () => {
+ expect(parseAssertedContext(null, null)).toEqual({ kind: "absent" });
+ });
+
+ it("reads half an assertion as unparseable, never as absent", () => {
+ // Half an assertion comes from a client that knows about the fence, so it
+ // must take the reconcile arm and not the leave-the-record arm.
+ expect(parseAssertedContext("3", null)).toEqual({ kind: "unparseable" });
+ expect(parseAssertedContext(null, RECORD_SCOPE_SELF)).toEqual({
+ kind: "unparseable",
+ });
+ });
+
+ it("reads the bootstrap sentinel as unparseable", () => {
+ expect(
+ parseAssertedContext(RECORD_FENCE_BOOTSTRAP, RECORD_FENCE_BOOTSTRAP),
+ ).toEqual({ kind: "unparseable" });
+ });
+
+ it.each([
+ ["not-a-number", "nonsense"],
+ ["-1", "negative"],
+ ["3.5", "fractional"],
+ ["1e3", "exponential"],
+ [" 3", "padded"],
+ ["", "empty"],
+ ["9".repeat(40), "over-long"],
+ ])("reads %s as unparseable (%s)", (value) => {
+ expect(parseAssertedContext(value, RECORD_SCOPE_SELF)).toEqual({
+ kind: "unparseable",
+ });
+ });
+
+ it("reads an over-long scope as unparseable before comparing it", () => {
+ expect(
+ parseAssertedContext("3", "x".repeat(MAX_ASSERTED_SCOPE_LENGTH + 1)),
+ ).toEqual({ kind: "unparseable" });
+ });
+
+ it("reads an empty scope as unparseable", () => {
+ expect(parseAssertedContext("3", "")).toEqual({ kind: "unparseable" });
+ });
+
+ it("reads the self sentinel as the null scope", () => {
+ expect(parseAssertedContext("0", RECORD_SCOPE_SELF)).toEqual({
+ kind: "asserted",
+ epoch: 0,
+ scope: null,
+ });
+ });
+
+ it("reads an account id as itself", () => {
+ expect(parseAssertedContext("12", OWNER)).toEqual({
+ kind: "asserted",
+ epoch: 12,
+ scope: OWNER,
+ });
+ });
+
+ it("round-trips a scope through the header value helper", () => {
+ for (const scope of [null, OWNER]) {
+ expect(parseAssertedContext("1", recordScopeHeaderValue(scope))).toEqual({
+ kind: "asserted",
+ epoch: 1,
+ scope,
+ });
+ }
+ });
+});
+
+describe("the contract module is safe in the browser bundle", () => {
+ const CONTRACT = join(
+ process.cwd(),
+ "src/lib/sharing/record-session-fence-contract.ts",
+ );
+
+ it("has no import statement at all", () => {
+ const src = readFileSync(CONTRACT, "utf8");
+ // Non-zero proof first: if the file were empty or misspelled, an
+ // "it contains no imports" assertion would pass for the wrong reason.
+ expect(src.length).toBeGreaterThan(1000);
+ expect(src).toContain("RECORD_EPOCH_HEADER");
+ expect(src).not.toMatch(/^\s*import[\s{*]/m);
+ expect(src).not.toMatch(/\bfrom\s+["']/);
+ expect(src).not.toMatch(/\brequire\s*\(/);
+ });
+
+ it("bounds an asserted scope at the same length as an account selector", () => {
+ expect(MAX_ASSERTED_SCOPE_LENGTH).toBe(MAX_SELECTOR_LENGTH);
+ });
+
+ it("names the two headers on the cookie transport's own namespace", () => {
+ expect(RECORD_EPOCH_HEADER).toBe("x-healthlog-record-epoch");
+ expect(RECORD_SCOPE_HEADER).toBe("x-healthlog-record-scope");
+ expect(RECORD_FENCE_ERROR_CODE).toBe("sharing.session.changed");
+ });
+});
+
+describe("assertRecordSessionFence", () => {
+ beforeEach(() => {
+ headerJar.clear();
+ vi.resetModules();
+ });
+
+ async function run(input: {
+ authMethod: "cookie" | "bearer";
+ recordEpoch?: number;
+ actingAsUserId?: string | null;
+ }): Promise<{ error: unknown; stamped: unknown }> {
+ const { assertRecordSessionFence } =
+ await import("@/lib/sharing/record-session-fence");
+ let stamped: unknown;
+ const { eventStorage } = await import("@/lib/logging/context");
+ const { WideEventBuilder } = await import("@/lib/logging/event-builder");
+ const evt = new WideEventBuilder();
+ let error: unknown = null;
+ await eventStorage.run(evt, async () => {
+ try {
+ await assertRecordSessionFence({
+ session: {
+ id: "session-1",
+ expiresAt: new Date(Date.now() + 60_000),
+ actingAsUserId: input.actingAsUserId ?? null,
+ recordEpoch: input.recordEpoch,
+ },
+ user: { id: "delegate" } as never,
+ authMethod: input.authMethod,
+ });
+ } catch (err) {
+ error = err;
+ }
+ stamped = evt.getRecordContext();
+ });
+ return { error, stamped };
+ }
+
+ it("serves a never-switched cookie session and stamps its context", async () => {
+ const { error, stamped } = await run({ authMethod: "cookie" });
+ expect(error).toBeNull();
+ expect(stamped).toEqual({ epoch: 0, scope: null });
+ });
+
+ it("serves a matching assertion and stamps the context it served under", async () => {
+ headerJar.set(RECORD_EPOCH_HEADER, "4");
+ headerJar.set(RECORD_SCOPE_HEADER, OWNER);
+ const { error, stamped } = await run({
+ authMethod: "cookie",
+ recordEpoch: 4,
+ actingAsUserId: OWNER,
+ });
+ expect(error).toBeNull();
+ expect(stamped).toEqual({ epoch: 4, scope: OWNER });
+ });
+
+ it("throws the 409 on a stale assertion, and still stamps the truth", async () => {
+ headerJar.set(RECORD_EPOCH_HEADER, "3");
+ headerJar.set(RECORD_SCOPE_HEADER, OWNER);
+ const { error, stamped } = await run({
+ authMethod: "cookie",
+ recordEpoch: 4,
+ actingAsUserId: OWNER,
+ });
+ expect((error as { statusCode?: number }).statusCode).toBe(409);
+ expect((error as { errorCode?: string }).errorCode).toBe(
+ RECORD_FENCE_ERROR_CODE,
+ );
+ // The refusal echoes the real context too, so a client that reconciles on
+ // the discard path and one that reconciles on the 409 path see the same
+ // thing on the wire.
+ expect(stamped).toEqual({ epoch: 4, scope: OWNER });
+ });
+
+ it("throws the 403 a pre-fence bundle recovers from when no header arrives", async () => {
+ const { error } = await run({
+ authMethod: "cookie",
+ recordEpoch: 1,
+ actingAsUserId: OWNER,
+ });
+ expect((error as { statusCode?: number }).statusCode).toBe(403);
+ expect((error as { errorCode?: string }).errorCode).toBe(
+ "sharing.access.denied",
+ );
+ });
+
+ it("leaves the Bearer transport alone and echoes nothing on it", async () => {
+ headerJar.set(RECORD_EPOCH_HEADER, "1");
+ headerJar.set(RECORD_SCOPE_HEADER, OTHER);
+ const { error, stamped } = await run({
+ authMethod: "bearer",
+ recordEpoch: 9,
+ actingAsUserId: OWNER,
+ });
+ expect(error).toBeNull();
+ expect(stamped).toBeUndefined();
+ });
+
+ it("treats an absent recordEpoch as zero, so a hand-built context is unfenced", async () => {
+ const { error } = await run({
+ authMethod: "cookie",
+ recordEpoch: undefined,
+ actingAsUserId: null,
+ });
+ expect(error).toBeNull();
+ });
+});
diff --git a/src/lib/sharing/record-session-fence-contract.ts b/src/lib/sharing/record-session-fence-contract.ts
new file mode 100644
index 000000000..8e4847759
--- /dev/null
+++ b/src/lib/sharing/record-session-fence-contract.ts
@@ -0,0 +1,233 @@
+/**
+ * v1.37.0 — what record a request believes it is addressing, as a contract both
+ * bundles can name.
+ *
+ * The problem is agreement, not authorization. `acting_as_user_id` plus the
+ * grant join already decides whether a caller MAY read a record; nothing here
+ * touches that. What this decides is whether the record the server is about to
+ * serve is the record the client formed its intent against. A browser holds
+ * several tabs on one session; a request that left a suspended tab before a
+ * switch landed arrives after it, perfectly authorized and asking the wrong
+ * question. So every cookie request that resolves a record asserts the context
+ * it believes it is in, and the server refuses when the belief no longer
+ * matches the session row.
+ *
+ * ─── Why this module has no imports ───────────────────────────────────────
+ *
+ * The client has to name the header constants, and both obvious homes poison
+ * the client bundle: `src/lib/api-handler.ts` imports Prisma at module scope
+ * and `src/lib/auth/acting-carrier.ts` imports `next/headers`. Either import
+ * would typecheck cleanly and fail `pnpm build`. So the constants, the parse
+ * and the verdict live here with ZERO imports — asserted by the unit test, not
+ * by convention — and the server half lives next door in
+ * `record-session-fence.ts`.
+ *
+ * ─── What the fence does NOT cover ────────────────────────────────────────
+ *
+ * Only the two record resolvers (`requireRecordAuth`, `requireGuardianAuth`)
+ * and the idempotency wrapper consult it. `requireActorAuth`, bare
+ * `requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa`,
+ * `GET /api/auth/me` and `POST /api/account/switch` deliberately do not, and a
+ * guard test freezes that list:
+ *
+ * * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
+ * unprovable context unrecoverable. It serves the caller's own rows by
+ * definition, so the selector cannot change what it returns.
+ * * bare `requireAuth` already refuses outright under an active switch.
+ * * `/api/auth/me` is the reconciliation bootstrap and has to answer while
+ * the client's context is unknown — it is where the client LEARNS the
+ * context it would otherwise have to assert.
+ *
+ * That list is about the auth HELPERS, not about routes, and it is not
+ * absolute: `POST /api/admin/backups/[id]/restore` composes the idempotency
+ * wrapper OUTSIDE `apiHandler`, so it is fence-visible through the idempotency
+ * arm even though `requireAdmin` itself is unfenced. That is wanted. An
+ * idempotent admin write under an unprovable context should be refused like any
+ * other.
+ *
+ * ─── Why there are two refusals and not one ───────────────────────────────
+ *
+ * A stale assertion gets 409 `sharing.session.changed`. An ABSENT assertion on
+ * a fenced session gets 403 `sharing.access.denied` instead, and the difference
+ * is a deploy-compatibility decision rather than a taxonomy:
+ *
+ * Header-absent means exactly one thing on the wire — a bundle that predates
+ * the fence. A fence-aware client attaches the headers to every same-origin
+ * request including the ones issued before `/api/auth/me` has resolved, using
+ * the `bootstrap` sentinel, so it can never reach the absent arm. And a
+ * pre-fence bundle cannot act on a 409: `src/lib/api/api-fetch.ts` special-cases
+ * no status at all, so the tab would render an error card and stay broken until
+ * a human reloaded. It CAN act on 403 `sharing.access.denied`, because the
+ * deployed bundle already mounts `SharedRecordGrantLossBridge`
+ * (`src/components/providers.tsx`), which on the first errored query carrying
+ * that code leaves the record and hard-navigates to `/` via `landOnRecord` in
+ * `src/hooks/use-account-switch.ts` — a full document navigation, which serves
+ * the fence-aware bundle. One reload, self-healing, no new code in the old
+ * bundle.
+ *
+ * So do not "simplify" the two arms into one. The 409 is a *reconcile, do not
+ * leave* instruction that only a fence-aware bundle can follow; handing it to a
+ * bundle that cannot follow it is the cliff this split exists to avoid.
+ */
+
+/** The asserted epoch. Cookie transport only; Bearer neither sends nor honours it. */
+export const RECORD_EPOCH_HEADER = "x-healthlog-record-epoch";
+
+/** The asserted canonical scope. Cookie transport only. */
+export const RECORD_SCOPE_HEADER = "x-healthlog-record-scope";
+
+/**
+ * The scope value naming the caller's own record.
+ *
+ * A sentinel rather than an empty header, so "I assert I am on my own record"
+ * and "I sent no scope at all" are different bytes on the wire. The fence's two
+ * refusal arms turn on exactly that distinction.
+ */
+export const RECORD_SCOPE_SELF = "self";
+
+/**
+ * What a fence-aware client asserts before `/api/auth/me` has told it anything.
+ *
+ * It is a PRESENT header, which is the point: it can never be mistaken for a
+ * pre-fence bundle, so it takes the 409 (reconcile) arm and not the 403 (leave
+ * the record) arm. On a session that never entered a shared record the
+ * exemption below serves it normally, so an ordinary sign-in never sees a 409
+ * during boot.
+ */
+export const RECORD_FENCE_BOOTSTRAP = "bootstrap";
+
+/** The stable code on the 409. The client branches on this, never on prose. */
+export const RECORD_FENCE_ERROR_CODE = "sharing.session.changed";
+
+/**
+ * Longest asserted scope worth comparing.
+ *
+ * Same posture and the same number as `MAX_SELECTOR_LENGTH` in
+ * `src/lib/auth/acting-carrier.ts` — a bound applied before any parse, so an
+ * over-long value is refused as such rather than measured. Deliberately NOT
+ * imported from there: that module pulls `next/headers`, and this one is in the
+ * client bundle. The unit test asserts the two numbers are equal so they cannot
+ * drift apart unnoticed.
+ */
+export const MAX_ASSERTED_SCOPE_LENGTH = 64;
+
+/**
+ * Longest asserted epoch worth parsing. A session's selector moves a handful of
+ * times a day at most; fifteen digits is far past any number this counter can
+ * reach and short enough that nothing large is ever coerced.
+ */
+const MAX_ASSERTED_EPOCH_LENGTH = 15;
+
+/**
+ * What the two headers said.
+ *
+ * `absent` and `unparseable` are separate cases because the fence answers them
+ * with different status codes — see the two-refusals note above. Collapsing
+ * them would hand a pre-fence bundle a status it cannot recover from.
+ */
+export type AssertedRecordContext =
+ | { kind: "absent" }
+ | { kind: "unparseable" }
+ | { kind: "asserted"; epoch: number; scope: string | null };
+
+/** What the fence decided. */
+export type RecordFenceVerdict = "pass" | "stale" | "unfenced-client";
+
+/**
+ * Turn the two raw header values into a closed union.
+ *
+ * No account-shape validation happens here and none is needed: the scope is
+ * compared by string equality against the session's real scope, so a bogus
+ * asserted account simply fails to match and yields `stale`. Adding a validator
+ * would be a second, weaker statement of what an account id is, and the weaker
+ * copy is the one that drifts.
+ */
+export function parseAssertedContext(
+ epochHeader: string | null,
+ scopeHeader: string | null,
+): AssertedRecordContext {
+ if (epochHeader === null && scopeHeader === null) return { kind: "absent" };
+
+ // Half an assertion is not an assertion. A fence-aware client sends both on
+ // every request, so one-of-two is a client that knows about the fence and got
+ // it wrong — which is the `stale` arm, not the pre-fence arm.
+ if (epochHeader === null || scopeHeader === null)
+ return { kind: "unparseable" };
+
+ if (
+ epochHeader.length > MAX_ASSERTED_EPOCH_LENGTH ||
+ scopeHeader.length > MAX_ASSERTED_SCOPE_LENGTH ||
+ scopeHeader.length === 0
+ ) {
+ return { kind: "unparseable" };
+ }
+
+ // The bootstrap sentinel and every other non-digit value land here together,
+ // which is correct: both are "a fence-aware client that cannot yet prove its
+ // context", and both must reconcile rather than be served.
+ if (!/^\d+$/.test(epochHeader)) return { kind: "unparseable" };
+
+ const epoch = Number(epochHeader);
+ if (!Number.isSafeInteger(epoch) || epoch < 0) return { kind: "unparseable" };
+
+ return {
+ kind: "asserted",
+ epoch,
+ scope: scopeHeader === RECORD_SCOPE_SELF ? null : scopeHeader,
+ };
+}
+
+/**
+ * The verdict itself, as a pure function of the transport, the session row and
+ * what the request asserted.
+ *
+ * Pure on purpose, and for the reason `decideActingCarrier` is pure: it is the
+ * one statement of the rule, and a rule that needs a request context to be read
+ * is a rule that gets re-implemented somewhere it can drift.
+ */
+export function recordSessionFenceVerdict(input: {
+ transport: "cookie" | "bearer";
+ /** The session row's counter. Sessions have none on the Bearer transport. */
+ sessionEpoch: number;
+ /** The account the session is pointed at, or null for its own record. */
+ sessionScope: string | null;
+ asserted: AssertedRecordContext;
+}): RecordFenceVerdict {
+ // Bearer carries its selector per request and accumulates no switch state, so
+ // there is no context for a token to be stale about. The frozen native
+ // contract is untouched: no header is read, required, or honoured here.
+ if (input.transport === "bearer") return "pass";
+
+ // The exemption, written as a conjunction even though migration 0302 makes
+ // the second clause redundant (it normalised every already-pointed row to 1,
+ // and the trigger keeps it there). A redundant clause over an authorization
+ // boundary is the right direction to be wrong in, and the redundancy is
+ // asserted by the unit test rather than assumed.
+ //
+ // What it buys: every browser tab open at deploy time that was never inside
+ // a shared record keeps working with no header at all, byte for byte. What it
+ // costs: a session at epoch 0 is served even if it asserts something odd —
+ // which cannot arise, because a client's adopted epoch comes from this very
+ // session and a session at 0 has never moved, and which in any case fails
+ // open onto the caller's OWN record.
+ if (input.sessionEpoch === 0 && input.sessionScope === null) return "pass";
+
+ // From here the session HAS been inside somebody's record, now or before, and
+ // stays fenced in both directions permanently. A stale tab believing it is
+ // inside the owner's record while the session is back on its own is the same
+ // defect as the reverse.
+ if (input.asserted.kind === "absent") return "unfenced-client";
+ if (input.asserted.kind === "unparseable") return "stale";
+ if (input.asserted.epoch !== input.sessionEpoch) return "stale";
+ // Belt and braces, deliberately: the epoch alone distinguishes transitions,
+ // and the scope additionally catches a selector that moved to a value the
+ // client did not expect. On the referential-action path both move at once.
+ if (input.asserted.scope !== input.sessionScope) return "stale";
+
+ return "pass";
+}
+
+/** The scope header value for a canonical scope. The inverse of the parse above. */
+export function recordScopeHeaderValue(scope: string | null): string {
+ return scope === null ? RECORD_SCOPE_SELF : scope;
+}
diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts
new file mode 100644
index 000000000..9b5dabfc4
--- /dev/null
+++ b/src/lib/sharing/record-session-fence.ts
@@ -0,0 +1,134 @@
+/**
+ * v1.37.0 — the server half of the record-session fence.
+ *
+ * The rule itself lives next door in `record-session-fence-contract.ts`, which
+ * imports nothing so the browser bundle can name the same headers. This file is
+ * the part that cannot be in a browser: it reads `next/headers`, it holds the
+ * refusals, and it stamps the wide event that becomes the response echo.
+ *
+ * Three things happen on every call, and the order matters:
+ *
+ * 1. Read the two asserted headers.
+ * 2. Stamp the context the request is ACTUALLY being served under onto the
+ * request-scoped wide event — on every call, pass or refuse. `apiHandler`
+ * turns that stamp into the response echo, and this is the only thing that
+ * ever stamps it. The alternative that looks equivalent is not: the wide
+ * event's `setActingAs` runs in exactly one place, inside
+ * `resolveSwitchedRecord`, so it never fires on the carrier-none path, on
+ * an actor surface, or on a public route — and a client rule of "2xx with
+ * no echo means discard" would then throw away every actor and public
+ * response, including the `/api/version` poll that runs on a timer.
+ * 3. Refuse, or return.
+ *
+ * ─── The atomicity property, stated once ──────────────────────────────────
+ *
+ * The epoch compared here and the `actingAsUserId` the resolver is about to
+ * serve under come from ONE `getSession()` row read — `findSessionByCookie`
+ * loads the whole row and both fields are projected off it. So "the scope the
+ * handler serves under" and "the epoch the fence validated" are the same fact
+ * read at the same instant, not two reads a switch can land between. That turns
+ * what would be a race window into a tautology, and it is why this function
+ * takes an `AuthContext` instead of resolving a session of its own. Do not add
+ * a second session read here for any reason.
+ *
+ * What that does NOT close, and nothing can: a request that has already passed
+ * the fence when a switch commits still completes under the context it was
+ * admitted with. Holding a lock across the handler's I/O would close it and was
+ * examined and rejected during v1.37.0 planning — it buys a disclosure window
+ * measured in milliseconds at the price of lock, pool, timeout and revocation
+ * availability defects. What covers it instead is the response echo: the client
+ * discards a response whose echoed context contradicts the one it is now on.
+ */
+import { headers } from "next/headers";
+
+import {
+ RECORD_EPOCH_HEADER,
+ RECORD_SCOPE_HEADER,
+ parseAssertedContext,
+ recordSessionFenceVerdict,
+ type AssertedRecordContext,
+} from "@/lib/sharing/record-session-fence-contract";
+import {
+ RecordSessionChangedError,
+ SharingAccessDeniedError,
+ type AuthContext,
+} from "@/lib/api-handler";
+import { annotate, getEvent } from "@/lib/logging/context";
+
+/**
+ * What this request asserted about its record context.
+ *
+ * `try`/`catch`-wrapped the way `readSelectorHeader` is: outside a Next.js
+ * request scope there is no header to read, and a direct unit invocation of a
+ * legacy route must not crash on the attempt. An unreadable header store is
+ * indistinguishable from a request that sent nothing, which is the honest
+ * answer — and on a fenced session that answer is refused, not served.
+ */
+export async function readAssertedRecordContext(): Promise {
+ try {
+ const headerList = await headers();
+ return parseAssertedContext(
+ headerList.get(RECORD_EPOCH_HEADER),
+ headerList.get(RECORD_SCOPE_HEADER),
+ );
+ } catch {
+ return { kind: "absent" };
+ }
+}
+
+/**
+ * Refuse this request if the record context it asserted is not the one its
+ * session is on.
+ *
+ * Called by `requireRecordAuth` and `requireGuardianAuth` immediately after
+ * `authenticateCaller()` and before `readActingCarrier()`, and independently by
+ * `withIdempotency` above the replay cache. Two independent evaluations against
+ * the same client assertion, with nothing carried between them: carrying a
+ * verdict across the wrapper/handler boundary would need request-scoped mutable
+ * state, which is a second source of truth for the same question. A switch
+ * landing between the two evaluations fails the second, so no handler body runs
+ * under a scope the asserted epoch does not imply.
+ */
+export async function assertRecordSessionFence(
+ auth: AuthContext,
+): Promise {
+ const transport = auth.authMethod === "bearer" ? "bearer" : "cookie";
+ const sessionEpoch = auth.session.recordEpoch ?? 0;
+ const sessionScope = auth.session.actingAsUserId ?? null;
+
+ // Stamped before the verdict, so a refusal echoes the truth too. The Bearer
+ // transport carries no session context to echo and gets none: the frozen
+ // native contract sends no fence header and receives none back.
+ if (transport === "cookie") {
+ getEvent()?.setRecordContext({ epoch: sessionEpoch, scope: sessionScope });
+ }
+
+ const asserted = await readAssertedRecordContext();
+ const verdict = recordSessionFenceVerdict({
+ transport,
+ sessionEpoch,
+ sessionScope,
+ asserted,
+ });
+
+ if (verdict === "pass") return;
+
+ if (verdict === "stale") {
+ annotate({ meta: { sharing_refusal: "record_context_stale" } });
+ throw new RecordSessionChangedError();
+ }
+
+ // `unfenced-client`: a bundle that predates the fence, inside or after a
+ // shared-record context. It gets the code it already recovers from — see the
+ // two-refusals note in the contract module.
+ //
+ // No audit row, and that follows the rule already written for the session
+ // carrier in `api-handler.ts`: a HEADER refusal is a per-request assertion by
+ // a client and earns a durable record, a SESSION-state refusal is ambient
+ // browser state and would bury the rows that matter under noise from the
+ // feature working as designed. This arm is the second kind — every
+ // navigation, prefetch and poll from one stale tab lands here until it
+ // reloads, and the reload is the designed outcome.
+ annotate({ meta: { sharing_refusal: "unfenced_client" } });
+ throw new SharingAccessDeniedError();
+}
From 45fec0fc1be9556d06abe5caed98f83a3a7d16aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 18:21:40 +0200
Subject: [PATCH 39/54] feat(02-12.1): refuse a record request whose context
has moved
- both record resolvers check the fence after authentication and before
the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
---
docs/api/openapi.yaml | 291 ++++++---
.../record-session-fence-guard.test.ts | 267 ++++++++
src/app/api/account/switch/route.ts | 82 ++-
src/app/api/auth/me/route.ts | 19 +
src/hooks/use-auth.ts | 15 +
src/hooks/use-record-capabilities.ts | 37 +-
.../__tests__/acting-account-resolver.test.ts | 128 +++-
src/lib/api-handler.ts | 47 +-
src/lib/openapi/routes/account-sharing.ts | 84 ++-
src/lib/openapi/routes/index.ts | 11 +-
src/lib/sharing/acting-session.ts | 61 +-
src/lib/sharing/record-session-fence.ts | 51 ++
src/lib/validations/account-sharing.ts | 22 +
.../acting-account-resolver.test.ts | 25 +
.../integration/record-session-fence.test.ts | 589 ++++++++++++++++++
15 files changed, 1631 insertions(+), 98 deletions(-)
create mode 100644 src/__tests__/record-session-fence-guard.test.ts
create mode 100644 tests/integration/record-session-fence.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 464b9e4b3..bbd1d6b42 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -12750,7 +12750,11 @@ paths:
should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated
here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every
following request re-checks the grant. `accountId: null` clears the switch and always works, including from
- inside a switched session: this endpoint is the way out."
+ inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a
+ session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write
+ conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409
+ `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and
+ retry once against the reconciled epoch."
requestBody:
required: true
content:
@@ -12764,6 +12768,15 @@ paths:
minLength: 1
maxLength: 64
- type: "null"
+ expectedEpoch:
+ description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a
+ previous switch. Present makes the write conditional: if another tab moved the selector first the
+ switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me.
+ Omitted keeps the unconditional write, which is what a client predating the record-session fence
+ sends. Cookie transport only."
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
required:
- accountId
responses:
@@ -12781,6 +12794,17 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"403": *a20
+ "409":
+ description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is
+ `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records —
+ so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only
+ request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a
+ client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a
+ third source of truth."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
"422":
description: Validation failed.
content:
@@ -36687,10 +36711,15 @@ components:
type: string
accountAccess:
$ref: "#/components/schemas/AccountAccess"
+ recordSession:
+ anyOf:
+ - $ref: "#/components/schemas/RecordSessionState"
+ - type: "null"
required:
- id
- username
- accountAccess
+ - recordSession
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this
@@ -36702,26 +36731,127 @@ components:
accounts:
type: array
items:
- $ref: "#/components/schemas/AccountAccessEntry"
- description: Every record this caller may open, newest grant first.
+ type: object
+ properties:
+ accountId:
+ type: string
+ minLength: 1
+ username:
+ type: string
+ minLength: 1
+ displayName:
+ anyOf:
+ - type: string
+ - type: "null"
+ access:
+ type: string
+ enum:
+ - read
+ - write
+ level:
+ type: string
+ enum:
+ - read
+ - write
+ - manage
+ recordKind:
+ type: string
+ enum:
+ - shared
+ - managed
+ sections:
+ anyOf:
+ - type: array
+ items:
+ type: string
+ enum:
+ - measurements
+ - medications
+ - labs
+ - profile
+ - illness
+ - mind
+ - cycle
+ - documents
+ - type: "null"
+ canWrite:
+ type: boolean
+ required:
+ - accountId
+ - username
+ - displayName
+ - access
+ - level
+ - recordKind
+ - sections
+ - canWrite
+ additionalProperties: false
active:
anyOf:
- - $ref: "#/components/schemas/AccountAccessEntry"
+ - type: object
+ properties:
+ accountId:
+ type: string
+ minLength: 1
+ username:
+ type: string
+ minLength: 1
+ displayName:
+ anyOf:
+ - type: string
+ - type: "null"
+ access:
+ type: string
+ enum:
+ - read
+ - write
+ level:
+ type: string
+ enum:
+ - read
+ - write
+ - manage
+ recordKind:
+ type: string
+ enum:
+ - shared
+ - managed
+ sections:
+ anyOf:
+ - type: array
+ items:
+ type: string
+ enum:
+ - measurements
+ - medications
+ - labs
+ - profile
+ - illness
+ - mind
+ - cycle
+ - documents
+ - type: "null"
+ canWrite:
+ type: boolean
+ required:
+ - accountId
+ - username
+ - displayName
+ - access
+ - level
+ - recordKind
+ - sections
+ - canWrite
+ additionalProperties: false
- type: "null"
- description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a
- banner can name the person without joining two fields. Null when the caller is in their own record, and
- ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
- one."
recordKind:
type: string
enum:
- self
- shared
- managed
- description: The server-resolved kind of the record currently in view. `self` applies when `active` is null.
canSwitch:
type: boolean
- description: Whether there is anywhere to switch to. Bind it directly.
required:
- accounts
- active
@@ -36733,86 +36863,30 @@ components:
be a second program deciding one person's access to a health record, and the two answer differently the first
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
not a missing field."
- AccountAccessEntry:
+ RecordSessionState:
type: object
properties:
- accountId:
- type: string
- description: The account whose record it is — the value POST /api/account/switch takes, and the value the
- X-HealthLog-Account header carries.
- username:
- type: string
- displayName:
+ epoch:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to
+ this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request,
+ and as `expectedEpoch` when switching.
+ scope:
anyOf:
- type: string
- type: "null"
- access:
- type: string
- enum:
- - read
- - write
- description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads
- canonical `level`.
- level:
- type: string
- enum:
- - read
- - write
- - manage
- description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
- surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
- which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
- recordKind:
- type: string
- enum:
- - self
- - shared
- - managed
- description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input.
- sections:
- anyOf:
- - type: array
- items:
- $ref: "#/components/schemas/ShareSection"
- - type: "null"
- description: The sections of that record this caller may open, or null for the entire record. Null is first-class and is
- what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open
- sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets
- the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal,
- which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the
- consent screen's reading order.
- canWrite:
- type: boolean
- description: "Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ
- one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE
- additionally admits is said by `level`. Resolved server-side. Render it; never derive it."
+ description: The account this session is pointed at, or null for its own record. Send it back as the
+ `X-HealthLog-Record-Scope` header, with `self` standing for null.
required:
- - accountId
- - username
- - displayName
- - access
- - level
- - recordKind
- - sections
- - canWrite
+ - epoch
+ - scope
additionalProperties: false
- description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
- here would resolve to a refusal. Clients paint their initials fallback."
- ShareSection:
- type: string
- enum:
- - measurements
- - medications
- - labs
- - profile
- - illness
- - mind
- - cycle
- - documents
- description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
- clustering the delegable read surfaces rather than from the module list — the two answer different questions and
- never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
- enum: a scope that named the whole record would be a narrowing that means its own absence."
+ description: Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which
+ carries its selector per request and accumulates no session state to be stale about. A client adopts this and
+ then asserts it on every same-origin request, so a request formed under a context that has since moved is
+ refused rather than served against the wrong record.
RecordActivityEnvelope:
type: object
properties:
@@ -37000,6 +37074,21 @@ components:
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
+ ShareSection:
+ type: string
+ enum:
+ - measurements
+ - medications
+ - labs
+ - profile
+ - illness
+ - mind
+ - cycle
+ - documents
+ description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
+ clustering the delegable read surfaces rather than from the module list — the two answer different questions and
+ never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
+ enum: a scope that named the whole record would be a narrowing that means its own absence."
AccountGrantState:
type: string
enum:
@@ -37172,6 +37261,13 @@ components:
AccountSwitchState:
type: object
properties:
+ recordSession:
+ anyOf:
+ - $ref: "#/components/schemas/RecordSessionState"
+ - type: "null"
+ description: "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an
+ unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the
+ moment the target equalled the current selector. Adopt this value; do not derive it."
actingAs:
anyOf:
- type: object
@@ -37198,6 +37294,7 @@ components:
additionalProperties: false
- type: "null"
required:
+ - recordSession
- actingAs
additionalProperties: false
description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and
@@ -37638,6 +37735,34 @@ components:
sync engine, device or notification registration, or any auth/refresh call: those are about the person holding
the token, not the record being read, and the account payload refuses the header outright for exactly that
reason."
+ RecordEpoch:
+ name: X-HealthLog-Record-Epoch
+ in: header
+ required: false
+ schema:
+ type: string
+ maxLength: 15
+ description: "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET
+ /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it.
+ Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been
+ pointed at another record is served without either header, which is what keeps a tab open across the deploy
+ working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a
+ mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry),
+ and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers
+ by leaving the record and reloading)."
+ RecordScope:
+ name: X-HealthLog-Record-Scope
+ in: header
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ description: The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self`
+ standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one
+ without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are
+ echoed back on any response that actually resolved a record scope, and a client discards a response whose echo
+ contradicts the context it is now on — a response carrying no echo resolved no record scope and is served
+ normally.
securitySchemes:
bearerAuth:
type: http
diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts
new file mode 100644
index 000000000..7d1f6de0d
--- /dev/null
+++ b/src/__tests__/record-session-fence-guard.test.ts
@@ -0,0 +1,267 @@
+/**
+ * Structural guards on the record-session fence.
+ *
+ * The behavioural proofs live in `src/lib/sharing/__tests__/record-session-fence.test.ts`
+ * (the verdict and the refusals), `tests/integration/record-session-fence.test.ts`
+ * (a refusal before any database work, with a positive control) and
+ * `tests/integration/record-session-fence-idempotency.test.ts` (a refusal above
+ * the replay cache). Those prove what today's code does. This file freezes what
+ * tomorrow's may not do, because the fence has one shape that makes it worth
+ * anything:
+ *
+ * * exactly two auth helpers consult it, and both consult it BEFORE they read
+ * 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.
+ *
+ * ## What these matchers cannot see
+ *
+ * Everything here is a text match over source, so the guard's own limit is
+ * worth writing down the way `session-surface-guard.test.ts` writes down its
+ * own: **a fence bypass under a different header name would slip every matcher
+ * below.** A future module that read `x-healthlog-record-context` and made its
+ * own admission decision would be invisible here, exactly as the share-link
+ * tree was invisible to a sweep that grepped only for `getSession`. The header
+ * leg pins the names that exist; it cannot pin the ones that do not yet.
+ *
+ * Nor can it prove ordering at runtime. The placement legs compare source
+ * offsets inside a function body, which is a proxy for execution order and
+ * holds only because neither call sits inside a branch. The runtime property —
+ * "no grant was looked up" — is proved by the integration file's `lastUsedAt`
+ * and `sharing.record.accessed` instruments and by the `findActiveGrant`
+ * zero-call spy, not here.
+ *
+ * Every leg asserts a non-zero match count before it asserts a set, because a
+ * guard whose matcher finds nothing reports success, and this repository has
+ * shipped several of those.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+function filesMatching(re: RegExp): string[] {
+ return sourceFiles().filter((rel) => re.test(read(rel)));
+}
+
+/**
+ * The source text of a top-level exported function, braces included. Same
+ * brace-matching walk `acting-account-boundary-guard.test.ts` uses.
+ */
+function functionBody(src: string, name: string): string {
+ const start = src.indexOf(`export async function ${name}(`);
+ if (start === -1) return "";
+ const open = src.indexOf("{", src.indexOf(")", start));
+ let depth = 0;
+ for (let i = open; i < src.length; i++) {
+ if (src[i] === "{") depth++;
+ else if (src[i] === "}") {
+ depth -= 1;
+ if (depth === 0) return src.slice(open, i + 1);
+ }
+ }
+ return "";
+}
+
+const API_HANDLER = read("lib/api-handler.ts");
+
+/** The two helpers that resolve a record and therefore must consult the fence. */
+const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
+
+/**
+ * The helpers that deliberately do NOT consult the fence.
+ *
+ * This is a statement about the HELPERS and must not be read as "no admin route
+ * is ever fenced". `POST /api/admin/backups/[id]/restore` composes
+ * `withIdempotency` OUTSIDE `apiHandler`, so it is fence-visible through the
+ * idempotency arm even though `requireAdmin` itself is on this list — and that
+ * is wanted, not an oversight. An idempotent admin write under an unprovable
+ * context should be refused like any other.
+ *
+ * Each entry's reason, once:
+ *
+ * * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
+ * unprovable context unrecoverable, and it serves the caller's own rows by
+ * definition, so the selector cannot change what it returns.
+ * * `requireAuth` bare already refuses outright under an active switch, and
+ * its routes are classified actor-only elsewhere.
+ * * `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` resolve through
+ * `getSession()` and are structurally unreachable by a switch.
+ */
+const UNFENCED_HELPERS = [
+ "requireAuth",
+ "requireActorAuth",
+ "requireAdmin",
+ "requireCookieAuth",
+ "requireFreshMfa",
+] as const;
+
+describe("the fence runs in exactly two auth helpers", () => {
+ it("is called by both record resolvers and by nothing else in api-handler", () => {
+ const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
+ // Non-zero proof: an emptied file would agree with an emptied expectation.
+ expect(calls.length).toBeGreaterThan(0);
+
+ const callers = FENCED_RESOLVERS.filter((name) =>
+ functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
+ );
+ expect(callers).toEqual([...FENCED_RESOLVERS]);
+ // Two call sites, one import. Anything else means a third caller appeared.
+ expect(calls.length).toBe(FENCED_RESOLVERS.length);
+ });
+
+ it.each(FENCED_RESOLVERS)(
+ "%s fences after authentication and before the carrier and the grant",
+ (name) => {
+ const body = functionBody(API_HANDLER, name);
+ expect(body.length).toBeGreaterThan(100);
+
+ const fence = body.indexOf("assertRecordSessionFence(");
+ const authenticate = body.indexOf("authenticateCaller(");
+ const carrier = body.indexOf("readActingCarrier(");
+ expect(fence).toBeGreaterThan(-1);
+ expect(authenticate).toBeGreaterThan(-1);
+ expect(carrier).toBeGreaterThan(-1);
+
+ // `authenticateCaller` NECESSARILY precedes the fence: it supplies the
+ // session row the fence reads, and the whole atomicity argument rests on
+ // both facts coming off that one read. So the invariant is "after
+ // authentication, before the carrier" — NOT "before the first database
+ // await", which would be false and must not be asserted here.
+ expect(fence).toBeGreaterThan(authenticate);
+ expect(fence).toBeLessThan(carrier);
+ },
+ );
+
+ it("fences before any grant lookup in the shared resolver path", () => {
+ // `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
+ // tail-call. Assert it appears nowhere ABOVE the fence in either body.
+ const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
+ expect(grantCalls.length).toBeGreaterThan(0);
+
+ for (const name of FENCED_RESOLVERS) {
+ const body = functionBody(API_HANDLER, name);
+ const fence = body.indexOf("assertRecordSessionFence(");
+ const grant = body.indexOf("findActiveGrant(");
+ // Either the body never names it (it is in the shared tail) or it names
+ // it after the fence. Both are the property; neither may invert.
+ if (grant !== -1) expect(fence).toBeLessThan(grant);
+ }
+ });
+
+ it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
+ const body = functionBody(API_HANDLER, name);
+ expect(body.length).toBeGreaterThan(50);
+ expect(body).not.toContain("assertRecordSessionFence");
+ });
+
+ it("the two unfenced routes stay unfenced", () => {
+ // `/api/auth/me` is the reconciliation bootstrap and must answer while the
+ // client's context is unknown; the switch endpoint is the way out of a
+ // context that cannot be proved. Fencing either would make an unprovable
+ // context unrecoverable.
+ const me = read("app/api/auth/me/route.ts");
+ const sw = read("app/api/account/switch/route.ts");
+ expect(me).toContain("requireActorAuth");
+ expect(sw).toContain("requireActorAuth");
+ expect(me).not.toContain("assertRecordSessionFence");
+ expect(sw).not.toContain("assertRecordSessionFence");
+ });
+});
+
+/**
+ * Both lists below grow as the feature lands, and that is the point rather than
+ * a defect: the idempotency arm and the browser's adopted-context module each
+ * arrive as a visible diff here rather than as a quiet new reader. Same posture
+ * as `CARRIER_ALLOWLIST` in `acting-account-boundary-guard.test.ts`.
+ */
+describe("the epoch has one writer and a closed reader set", () => {
+ it("is written in exactly one file", () => {
+ // The SQL spelling only. `acting-session.ts` owns the conditional write
+ // because it needs `RETURNING`, which no model delegate expresses — and
+ // keeping the statement there is what keeps this list at one.
+ const writers = filesMatching(/record_epoch/);
+ expect(writers.length).toBeGreaterThan(0);
+ expect(writers).toEqual(["lib/sharing/acting-session.ts"]);
+ });
+
+ it("is read by a closed list and nothing else", () => {
+ // The identifier, bounded so `recordEpochParameter` in the OpenAPI module
+ // does not count — that file publishes a header name, it does not read a
+ // column.
+ const readers = filesMatching(/\brecordEpoch(?![\w$])/);
+ expect(readers.length).toBeGreaterThan(0);
+ expect(readers).toEqual(
+ [
+ // Projects it off the session row it already loaded.
+ "lib/auth/session.ts",
+ // Carries it on the auth context the fence is handed.
+ "lib/api-handler.ts",
+ // Compares it. The one place that decides anything from it.
+ "lib/sharing/record-session-fence.ts",
+ ].sort(),
+ );
+ });
+
+ it("does not leak the raw counter into the browser's vocabulary", () => {
+ // The client never names the epoch as a column. It receives
+ // `recordSession: { epoch, scope }` and asserts it back as a header, which
+ // is why no file under `hooks/` or `components/` appears in the list above.
+ // A client that started naming `recordEpoch` would be reaching past the
+ // published shape into the row.
+ const clientReaders = filesMatching(/\brecordEpoch(?![\w$])/).filter(
+ (rel) =>
+ rel.startsWith("hooks/") ||
+ rel.startsWith("components/") ||
+ rel.startsWith("app/"),
+ );
+ expect(clientReaders).toEqual([]);
+ });
+});
+
+describe("the header names live in a closed set of files", () => {
+ it("is declared, read and published — and nowhere else", () => {
+ // Mirrors the `x-healthlog-account` leg in
+ // `acting-account-boundary-guard.test.ts`. A route that started reading
+ // these headers itself would be making an admission decision the fence
+ // exists to make once.
+ const namers = filesMatching(
+ /RECORD_EPOCH_HEADER|RECORD_SCOPE_HEADER|x-healthlog-record-(epoch|scope)/i,
+ );
+ expect(namers.length).toBeGreaterThan(0);
+ expect(namers).toEqual([
+ // Publishes them to the clients that have to send them.
+ "lib/openapi/routes/account-sharing.ts",
+ // Declares them.
+ "lib/sharing/record-session-fence-contract.ts",
+ // Reads them off the request scope, and writes the response echo.
+ // `apiHandler` attaches the echo through this module's helper rather than
+ // naming the headers itself, which is what keeps this list at four.
+ "lib/sharing/record-session-fence.ts",
+ ]);
+ });
+
+ it("keeps the 409's code in the contract module and the client that branches on it", () => {
+ const namers = filesMatching(/sharing\.session\.changed/);
+ expect(namers.length).toBeGreaterThan(0);
+ // The error class reads the constant rather than restating the string, so
+ // `api-handler.ts` is deliberately NOT on this list.
+ expect(namers).toContain("lib/sharing/record-session-fence-contract.ts");
+ });
+});
diff --git a/src/app/api/account/switch/route.ts b/src/app/api/account/switch/route.ts
index bcd721fca..073f739c5 100644
--- a/src/app/api/account/switch/route.ts
+++ b/src/app/api/account/switch/route.ts
@@ -21,11 +21,37 @@
* authorises nothing, and the resolver re-checks the grant on every request
* that follows. A value stranded here by a revocation seconds later confers
* exactly nothing.
+ *
+ * v1.37.0 — unfenced, deliberately, and the reason is the same one that makes
+ * it an actor surface: this is the way back. A session whose context cannot be
+ * proved must still be able to leave the record it may or may not be in, and
+ * fencing the exit would make an unprovable context unrecoverable.
+ *
+ * What it takes instead is an optional `expectedEpoch`, which turns the
+ * selector write into a compare-and-set. Two tabs pressing the switcher at once
+ * both name the epoch they saw; one `UPDATE` matches, the loser gets zero rows
+ * and the same 409 the fence raises, and the browser reconciles through
+ * `/api/auth/me` before retrying. That is the monotonic ordering a server-side
+ * pending lease was proposed for, in one statement, with no intermediate state
+ * for a crashed initiator to strand.
+ *
+ * An ABSENT `expectedEpoch` keeps the unconditional write, and the two halves
+ * of what that means are worth stating together:
+ *
+ * * It is reachable only by a bundle that predates the fence. The switcher is
+ * rendered from `/api/auth/me` data, so a fence-aware client has adopted a
+ * context before it can offer a switch at all — it never omits the field.
+ * * It can therefore clobber a concurrent fenced switch. That is accepted:
+ * the fenced tab's next request asserts an epoch the row no longer holds,
+ * it takes the 409, and it reconciles to the truth; the pre-fence tab
+ * reloads into a fence-aware bundle immediately afterwards. Fail-closed and
+ * self-healing, bounded to the deploy window.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
+ RecordSessionChangedError,
requireActorAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
@@ -39,7 +65,10 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
-import { pointSessionAt } from "@/lib/sharing/acting-session";
+import {
+ pointSessionAt,
+ pointSessionAtIfUnchanged,
+} from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
@@ -66,18 +95,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
- const { accountId } = parsed.data;
+ const { accountId, expectedEpoch } = parsed.data;
const ip = getClientIp(request);
+ /**
+ * Move the selector, conditionally when the caller named an epoch.
+ *
+ * Returns the record context the session ends up in, so the response can
+ * publish it — this endpoint and `/api/auth/me` are the only two responses a
+ * client may ADOPT a context from, and both carry it in the body. Every other
+ * response's echo is used to validate and never to adopt.
+ *
+ * `null` on the unconditional arm, and that is the honest answer rather than
+ * a gap: the epoch after an unconditional write is whatever the trigger
+ * decided, this statement did not read it back, and a number computed here
+ * would be wrong the moment the target equalled the current selector (the
+ * trigger's `IS DISTINCT FROM` clause declines to move on a no-op write). A
+ * caller that omitted `expectedEpoch` is by construction a bundle that would
+ * not read the field anyway, and `/api/auth/me` states the truth on its next
+ * boot.
+ */
+ async function moveSelector(
+ target: string | null,
+ ): Promise<{ epoch: number; scope: string | null } | null | "stale"> {
+ if (expectedEpoch === undefined) {
+ await pointSessionAt(auth.session.id, auth.user.id, target);
+ return null;
+ }
+ const outcome = await pointSessionAtIfUnchanged(
+ auth.session.id,
+ auth.user.id,
+ target,
+ expectedEpoch,
+ );
+ if (outcome.kind === "stale") return "stale";
+ return { epoch: outcome.epoch, scope: target };
+ }
+
if (accountId === null) {
- await pointSessionAt(auth.session.id, auth.user.id, null);
+ const moved = await moveSelector(null);
+ if (moved === "stale") {
+ annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
+ throw new RecordSessionChangedError();
+ }
await auditLog("sharing.switch.off", {
userId: auth.user.id,
details: {},
ipAddress: ip,
}).catch(() => {});
annotate({ action: { name: "sharing.switch.off" } });
- return apiSuccess({ actingAs: null });
+ return apiSuccess({ actingAs: null, recordSession: moved });
}
// The same predicate the resolver uses, on the same table, with no second
@@ -105,7 +172,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true, username: true, displayName: true },
});
- await pointSessionAt(auth.session.id, auth.user.id, owner.id);
+ const moved = await moveSelector(owner.id);
+ if (moved === "stale") {
+ annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
+ throw new RecordSessionChangedError();
+ }
await auditLog("sharing.switch.on", {
userId: auth.user.id,
@@ -125,5 +196,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
displayName: owner.displayName,
access: grant.access,
},
+ recordSession: moved,
});
});
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index ee0d395f5..1c2a7520d 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -31,6 +31,7 @@ import {
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
+import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
export const dynamic = "force-dynamic";
@@ -173,5 +174,23 @@ export const GET = apiHandler(async () => {
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
+ // v1.37.0 — the record-session fence's bootstrap, and the only place a
+ // browser can learn its context from scratch.
+ //
+ // `epoch` is the session row's selector counter; `scope` is the account the
+ // session is pointed at, or null for its own record. The client adopts both
+ // and attaches them to every subsequent same-origin request, so the server
+ // can refuse a request formed under a context that has since moved. This
+ // response and `POST /api/account/switch` are the only two a client may
+ // ADOPT from — every other response carries the context in headers, and
+ // those are used to validate a response, never to learn one.
+ //
+ // Null on the Bearer transport, which has no session row and no switch
+ // state to be stale about. A native client neither sends nor receives the
+ // fence headers; the frozen contract is untouched.
+ // Derived by the fence rather than read off the context here: the columns
+ // behind it have one reader by design, and a route that projected them
+ // itself would be a second statement of what a record context is.
+ recordSession: recordSessionForPayload(auth),
});
});
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index 01ff12b53..6f2a87cef 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -210,6 +210,21 @@ export interface AuthUser {
* server-side switch cannot paint owner controls in the target record.
*/
accountAccessStatus?: AccountAccessStatus;
+ /**
+ * v1.37.0 — the record-session context the server says this browser is in.
+ *
+ * `epoch` counts how many times this session's record selector has moved and
+ * `scope` names the record it is pointed at (null for one's own). Adopted by
+ * `fetchMe` and then attached to every same-origin request, so a request
+ * formed under a context that has since moved is refused rather than served
+ * against the wrong record.
+ *
+ * Null on the Bearer transport, which carries no such state. Optional so
+ * existing `AuthUser` fixtures stay valid; an absent field is an older server
+ * image and leaves the client unadopted, which the `bootstrap` sentinel makes
+ * safe.
+ */
+ recordSession?: { epoch: number; scope: string | null } | null;
}
/**
diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts
index 6e8bf8c38..f1991e3df 100644
--- a/src/hooks/use-record-capabilities.ts
+++ b/src/hooks/use-record-capabilities.ts
@@ -118,11 +118,13 @@ export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
accessRefused = false,
recordSessionPending = false,
+ contextUnproven = false,
): RecordCapabilities {
- if (accessRefused || recordSessionPending) {
+ if (accessRefused || recordSessionPending || contextUnproven) {
return {
accessRefused: accessRefused || undefined,
- recordSessionPending: recordSessionPending || undefined,
+ recordSessionPending:
+ recordSessionPending || contextUnproven || undefined,
inSharedRecord: true,
canWrite: false,
canAdd: false,
@@ -174,6 +176,36 @@ export function resolveRecordCapabilities(
* withdraws is a much smaller lie than one that stays and 403s, and the
* banner lands from the same query in the same frame.
*/
+/**
+ * v1.37.0 — do the two answers about "which record is this" agree.
+ *
+ * `/api/auth/me` publishes the question twice, from two different angles, and
+ * that is deliberate rather than redundant:
+ *
+ * * `accountAccess.active` is the RE-DECIDED answer. An entry reaches it only
+ * by surviving a live-grant pass, so a selector left behind by a lapsed
+ * grant reads as "not switched".
+ * * `recordSession.scope` is the RAW selector, the same value 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 makes
+ * 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` was introduced to end. So a disagreement holds instead.
+ *
+ * A null or absent `recordSession` is not a disagreement. It is the Bearer
+ * transport (no session row, no switch state) or a server image that predates
+ * the field, and neither is a reason to blank the app.
+ */
+export function recordContextIsUnproven(
+ recordSession: { epoch: number; scope: string | null } | null | undefined,
+ active: AccountAccessEntry | null | undefined,
+): boolean {
+ if (recordSession == null) return false;
+ return recordSession.scope !== (active?.accountId ?? null);
+}
+
export function useRecordCapabilities(): RecordCapabilities {
const { user } = useAuth();
const transition = useRecordSessionTransition();
@@ -181,6 +213,7 @@ export function useRecordCapabilities(): RecordCapabilities {
user?.accountAccess?.active,
user?.accountAccessStatus === "invalid",
transition.phase !== "ready",
+ recordContextIsUnproven(user?.recordSession, user?.accountAccess?.active),
);
}
diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts
index 2e6a4e045..affbe2c84 100644
--- a/src/lib/__tests__/acting-account-resolver.test.ts
+++ b/src/lib/__tests__/acting-account-resolver.test.ts
@@ -151,10 +151,16 @@ function user(
/**
* Sign the delegate in over the cookie transport.
*
- * `recordEpoch` defaults to 0, which is the record-session fence's exemption:
- * a session that has never been pointed at another record is served without a
- * fence header, so every pre-fence case in this file keeps meaning what it
- * meant. Cases that want the fence live pass an epoch explicitly.
+ * v1.37.0 — this also attaches a TRUTHFUL record-session assertion, because
+ * that is what the shipped browser client does on every same-origin request.
+ * Without it every switched case in this file would be testing the fence's
+ * pre-fence-bundle arm rather than the resolver arm it was written for: a
+ * session pointed at a record is fenced, and a request carrying no assertion at
+ * all is refused with 403 before the resolver decides anything.
+ *
+ * `recordEpoch` defaults to 0. A session at 0 pointed at its own record is the
+ * fence's exemption, so the un-switched cases are byte-for-byte what they were.
+ * Cases that want a stale or absent assertion override the headers themselves.
*/
function signedInCookie(
actingAsUserId: string | null = null,
@@ -170,6 +176,8 @@ function signedInCookie(
},
user: user(DELEGATE, role) as never,
});
+ headerJar.set("x-healthlog-record-epoch", String(recordEpoch));
+ headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self");
}
/** Sign the delegate in over the Bearer transport. */
@@ -906,3 +914,115 @@ describe("the guardian resolver gates on the marker, not the grant", () => {
expect(fencedBody).not.toContain("managed");
});
});
+
+/**
+ * v1.37.0 — the record-session fence, from the resolver's side.
+ *
+ * The integration file proves the refusal against a real database with the
+ * `lastUsedAt` / `sharing.record.accessed` instrument. What this adds is the
+ * cheapest falsifiable statement of the same property: the grant query is not
+ * issued at all.
+ *
+ * The instrument is `prisma.accountGrant.findFirst`, not a mocked
+ * `findActiveGrant`. This file deliberately does not mock `@/lib/sharing/grants`
+ * — the resolver consuming that module's own idea of "active" is a property
+ * under test here, and a mocked predicate would report it working while it was
+ * broken. The Prisma spy is one layer lower, is honest about the same fact, and
+ * costs nothing.
+ */
+describe("the record-session fence, above the grant lookup", () => {
+ it("issues no grant query when the asserted epoch is behind", async () => {
+ signedInCookie(OWNER, "USER", 4);
+ liveGrant();
+ headerJar.set("x-healthlog-record-epoch", "3");
+ headerJar.set("x-healthlog-record-scope", OWNER);
+
+ const res = await route(() => requireRecordAuth("read", "measurements"))();
+
+ expect(res.status).toBe(409);
+ expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
+ // The whole point: nothing was asked of the grant table.
+ expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
+ // And nothing was stamped on it either.
+ expect(touched).toEqual([]);
+ });
+
+ it("issues no grant query when the client sent no assertion at all", async () => {
+ signedInCookie(OWNER, "USER", 4);
+ liveGrant();
+ // A bundle that predates the fence: no assertion on the wire at all.
+ headerJar.delete("x-healthlog-record-epoch");
+ headerJar.delete("x-healthlog-record-scope");
+
+ const res = await route(() => requireRecordAuth("read", "measurements"))();
+
+ expect(res.status).toBe(403);
+ expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
+ expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("POSITIVE CONTROL: a matching assertion does reach the grant query", async () => {
+ // Without this the test above would pass for a resolver that never queried
+ // the grant table at all.
+ signedInCookie(OWNER, "USER", 4);
+ liveGrant();
+ headerJar.set("x-healthlog-record-epoch", "4");
+ headerJar.set("x-healthlog-record-scope", OWNER);
+
+ const ctx = await resolveInRoute(() =>
+ requireRecordAuth("read", "measurements"),
+ );
+
+ expect(ctx.user.id).toBe(OWNER);
+ expect(prisma.accountGrant.findFirst).toHaveBeenCalled();
+ expect(touched).toEqual(["grant-1"]);
+ });
+
+ it("guards the guardian resolver the same way", async () => {
+ signedInCookie(OWNER, "USER", 2);
+ userRows.set(OWNER, user(OWNER, "USER", new Date()));
+ liveGrant({ access: "MANAGE" });
+ headerJar.set("x-healthlog-record-epoch", "1");
+ headerJar.set("x-healthlog-record-scope", OWNER);
+
+ const res = await route(() => requireGuardianAuth())();
+
+ expect(res.status).toBe(409);
+ expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("leaves a never-switched session alone, header or no header", async () => {
+ // The exemption. A session at epoch 0 pointed at its own record is served
+ // byte-for-byte as it was before the fence existed.
+ signedInCookie(null, "USER", 0);
+ headerJar.delete("x-healthlog-record-epoch");
+ headerJar.delete("x-healthlog-record-scope");
+ const ctx = await resolveInRoute(() =>
+ requireRecordAuth("read", "measurements"),
+ );
+ expect(ctx.user.id).toBe(DELEGATE);
+ expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("echoes the context it served under, and only when it resolved one", async () => {
+ signedInCookie(OWNER, "USER", 4);
+ liveGrant();
+ headerJar.set("x-healthlog-record-epoch", "4");
+ headerJar.set("x-healthlog-record-scope", OWNER);
+ const served = await route(() =>
+ requireRecordAuth("read", "measurements"),
+ )();
+ expect(served.headers.get("x-healthlog-record-epoch")).toBe("4");
+ expect(served.headers.get("x-healthlog-record-scope")).toBe(OWNER);
+
+ // An actor surface decides nothing about a record and therefore echoes
+ // nothing. A client that discarded on ABSENCE would throw this away, and
+ // with it the public `/api/version` poll.
+ headerJar.clear();
+ signedInCookie(null, "USER", 0);
+ const actor = await route(() => requireActorAuth())();
+ expect(actor.status).toBe(200);
+ expect(actor.headers.get("x-healthlog-record-epoch")).toBeNull();
+ expect(actor.headers.get("x-healthlog-record-scope")).toBeNull();
+ });
+});
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
index e434a4520..9d91d6d47 100644
--- a/src/lib/api-handler.ts
+++ b/src/lib/api-handler.ts
@@ -38,7 +38,10 @@ import {
} from "./sharing/grants";
import type { ShareScope } from "./sharing/scope";
import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract";
-import { assertRecordSessionFence } from "./sharing/record-session-fence";
+import {
+ assertRecordSessionFence,
+ attachRecordContextEcho,
+} from "./sharing/record-session-fence";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
@@ -400,6 +403,28 @@ export function apiHandler Promise>(
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
+ // v1.37.0 — echo the record context this response was actually served
+ // under, when one was decided. The value comes from the wide event and
+ // from nowhere else: the fence stamps it on every call it makes, so
+ // exactly the responses that resolved a record scope carry it and
+ // everything else — public routes, actor surfaces, admin, `/me`, static —
+ // carries nothing.
+ //
+ // Deliberately NOT derived here with a `getSession()`. That would put a
+ // session read on every public route, and it would make the echo a second
+ // derivation of the context rather than a report of the one that was
+ // used, which is the difference between "what this response served" and
+ // "what a later read thinks is true".
+ //
+ // Absence is therefore meaningful and safe: the client discards a
+ // response whose echo CONTRADICTS its adopted context, and serves one
+ // that carries no echo normally. A discard-on-absence rule would throw
+ // away the `/api/version` poll on every cycle.
+ //
+ // The two header names stay inside the fence module, which is what keeps
+ // them to four files overall (declared, read, attached client-side,
+ // published) — see `src/__tests__/record-session-fence-guard.test.ts`.
+ attachRecordContextEcho(nr.headers, evt.getRecordContext());
return nr;
});
};
@@ -832,6 +857,21 @@ export async function requireRecordAuth(
domain: ShareScope,
): Promise {
const auth = await authenticateCaller();
+ // v1.37.0 — before any carrier is read, any grant is looked up, or any record
+ // row is touched: does this request still believe what its session believes?
+ //
+ // The atomicity that makes this a tautology rather than a race: the epoch the
+ // fence compares and the `actingAsUserId` the carrier below is built from are
+ // BOTH projected off the one session row `authenticateCaller()` already
+ // loaded. The scope this handler serves under and the epoch it validated are
+ // therefore the same fact read at the same instant, not two reads a commit
+ // can land between. Do not add a second session read here to "refresh" it —
+ // that would reintroduce exactly the window this ordering removes.
+ //
+ // `authenticateCaller` necessarily runs first because it supplies the row the
+ // fence reads, so the invariant is "before the carrier", not "before the
+ // first database await".
+ await assertRecordSessionFence(auth);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
@@ -903,6 +943,11 @@ function escalate(need: GrantNeed): GrantNeed {
*/
export async function requireGuardianAuth(): Promise {
const auth = await authenticateCaller();
+ // Same placement and the same reason as `requireRecordAuth` above: the
+ // guardian surfaces administer a managed profile, so a request arriving under
+ // a context that has since moved must be refused before the marker is read,
+ // not after.
+ await assertRecordSessionFence(auth);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index 49511044f..cb3decceb 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -118,8 +118,45 @@ const grantListResponse = z
"Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first.",
});
+/**
+ * The record-session context, as the two surfaces a client may ADOPT one from
+ * publish it.
+ *
+ * Both carry it in the response BODY, and that is the rule rather than a
+ * coincidence: every other response reports its context in headers, and those
+ * are for validating a response the client already has, never for learning one.
+ * Two body-carrying sources, both actor surfaces that resolve canonical session
+ * state, and no third path — which is why the 409 refusal below carries only an
+ * error code and never the current epoch.
+ */
+const recordSessionState = z
+ .object({
+ epoch: z
+ .number()
+ .int()
+ .describe(
+ "How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request, and as `expectedEpoch` when switching.",
+ ),
+ scope: z
+ .string()
+ .nullable()
+ .describe(
+ "The account this session is pointed at, or null for its own record. Send it back as the `X-HealthLog-Record-Scope` header, with `self` standing for null.",
+ ),
+ })
+ .meta({
+ id: "RecordSessionState",
+ description:
+ "Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which carries its selector per request and accumulates no session state to be stale about. A client adopts this and then asserts it on every same-origin request, so a request formed under a context that has since moved is refused rather than served against the wrong record.",
+ });
+
const switchResponse = z
.object({
+ recordSession: recordSessionState
+ .nullable()
+ .describe(
+ "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the moment the target equalled the current selector. Adopt this value; do not derive it.",
+ ),
actingAs: z
.object({
accountId: z.string(),
@@ -182,6 +219,12 @@ const sharingRefusal = {
content: { "application/json": { schema: errorEnvelope } },
};
+const recordSessionChanged = {
+ description:
+ "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a third source of truth.",
+ content: { "application/json": { schema: errorEnvelope } },
+};
+
/**
* The per-request account selector, published once.
*
@@ -209,6 +252,43 @@ export const accountSelectorParameter: NonNullable<
"Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.",
};
+/**
+ * The record-session fence's two request headers, published once.
+ *
+ * Cookie transport only, and stated as such in both descriptions: a Bearer
+ * client neither sends these nor receives them back, and the native contract is
+ * unchanged by the fence in every direction. A browser client attaches both to
+ * every same-origin request — there is no per-route opt-in, because a route
+ * that had to remember is a route that will forget.
+ *
+ * Defined here rather than inline for the same reason the account selector
+ * above is: `src/__tests__/record-session-fence-guard.test.ts` holds the header
+ * names to exactly four files — the contract module that declares them, the
+ * server fence that reads them, the client module that attaches them, and this
+ * one, which publishes them.
+ */
+export const recordEpochParameter: NonNullable<
+ NonNullable["parameters"]
+>[string] = {
+ name: "X-HealthLog-Record-Epoch",
+ in: "header",
+ required: false,
+ schema: { type: "string", maxLength: 15 },
+ description:
+ "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it. Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been pointed at another record is served without either header, which is what keeps a tab open across the deploy working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry), and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers by leaving the record and reloading).",
+};
+
+export const recordScopeParameter: NonNullable<
+ NonNullable["parameters"]
+>[string] = {
+ name: "X-HealthLog-Record-Scope",
+ in: "header",
+ required: false,
+ schema: { type: "string", maxLength: 64 },
+ description:
+ "The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self` standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are echoed back on any response that actually resolved a record scope, and a client discards a response whose echo contradicts the context it is now on — a response carrying no echo resolved no record scope and is served normally.",
+};
+
// ── What the client is told it may do ────────────────────────────────────────
export const accountAccessBlock = accountAccessBlockSchema.meta({
@@ -231,6 +311,7 @@ const accountPayload = z
id: z.string(),
username: z.string(),
accountAccess: accountAccessBlock,
+ recordSession: recordSessionState.nullable(),
})
.meta({
id: "AccountPayload",
@@ -466,7 +547,7 @@ export const accountSharingPaths: NonNullable = {
tags: ["Account sharing"],
summary: "Act on a granted record, or return to your own",
description:
- "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out.",
+ "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and retry once against the reconciled epoch.",
requestBody: {
required: true,
content: { "application/json": { schema: switchAccountSchema } },
@@ -487,6 +568,7 @@ export const accountSharingPaths: NonNullable = {
content: { "application/json": { schema: errorEnvelope } },
},
"403": sharingRefusal,
+ "409": recordSessionChanged,
"422": {
description: "Validation failed.",
content: { "application/json": { schema: errorEnvelope } },
diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts
index c64c16678..514f958fa 100644
--- a/src/lib/openapi/routes/index.ts
+++ b/src/lib/openapi/routes/index.ts
@@ -24,6 +24,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
import {
accountSelectorParameter,
accountSharingPaths,
+ recordEpochParameter,
+ recordScopeParameter,
} from "./account-sharing";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { allergyPaths } from "./allergies";
@@ -156,7 +158,14 @@ export const openApiComponents: NonNullable = {
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
// holding the header's name to two files: the resolver that reads it, and
// the one place that publishes it.
- parameters: { AccountSelector: accountSelectorParameter },
+ // v1.37.0 — the record-session fence's two request headers, defined in the
+ // same module for the same reason: the fence guard holds their names to four
+ // files, and this index references rather than restates them.
+ parameters: {
+ AccountSelector: accountSelectorParameter,
+ RecordEpoch: recordEpochParameter,
+ RecordScope: recordScopeParameter,
+ },
securitySchemes: {
bearerAuth: {
type: "http",
diff --git a/src/lib/sharing/acting-session.ts b/src/lib/sharing/acting-session.ts
index 062fdfe49..9d1bfcabe 100644
--- a/src/lib/sharing/acting-session.ts
+++ b/src/lib/sharing/acting-session.ts
@@ -24,8 +24,27 @@ import type { Prisma } from "@/generated/prisma/client";
/**
* The narrow client slice these functions need, so a caller can hand them a
* transaction handle. Same shape as `grants.ts` uses for `accountGrant`.
+ *
+ * v1.37.0 — widened by one member. The conditional switch below needs
+ * `RETURNING`, which no model delegate can express, so it goes through
+ * `$queryRaw`. Widening the slice is what keeps the raw statement in THIS file:
+ * the alternative is a caller that names `record_epoch` itself, and "only
+ * `acting-session.ts` writes the selector" is the property the boundary guard
+ * in `src/__tests__/acting-account-boundary-guard.test.ts` exists to hold. Both
+ * existing call sites already pass `prisma` or a transaction handle, and both
+ * satisfy the wider shape unchanged.
*/
-type SessionDb = Pick;
+type SessionDb = Pick;
+
+/**
+ * The outcome of a conditional switch.
+ *
+ * `stale` is not a failure of the write; it is another tab having got there
+ * first. The caller turns it into the same 409 the fence raises, and the
+ * browser reconciles through `/api/auth/me` rather than retrying blind.
+ */
+export type PointSessionOutcome =
+ { kind: "applied"; epoch: number } | { kind: "stale" };
/**
* Point one browser session at an account's record, or (with `null`) back at
@@ -48,6 +67,46 @@ export async function pointSessionAt(
});
}
+/**
+ * v1.37.0 — the same write, conditional on the epoch the caller believes the
+ * session is at.
+ *
+ * One statement, and that is the whole design. Two tabs pressing the switcher
+ * at the same moment both name the epoch they saw; exactly one `UPDATE` matches
+ * the `WHERE`, and the loser gets zero rows back. That is the monotonic
+ * ordering guarantee, expressed as a compare-and-set rather than as a
+ * server-side pending lease with an expiry and an intermediate state that a
+ * crashed initiator can strand.
+ *
+ * `RETURNING "record_epoch"` reads the value the TRIGGER assigned, in the same
+ * statement that assigned it. So the epoch handed back to the client is the one
+ * the database committed and not a number this file computed — which matters,
+ * because a client that adopts a computed epoch would then assert a context the
+ * row does not hold and refuse itself on its very next request.
+ *
+ * Parameter-bound throughout: `$queryRaw` as a tagged template, no
+ * interpolation, no `Unsafe` variant.
+ */
+export async function pointSessionAtIfUnchanged(
+ sessionId: string,
+ userId: string,
+ actingAsUserId: string | null,
+ expectedEpoch: number,
+ db: SessionDb = prisma,
+): Promise {
+ const rows = await db.$queryRaw<{ record_epoch: number }[]>`
+ UPDATE "sessions"
+ SET "acting_as_user_id" = ${actingAsUserId}
+ WHERE "id" = ${sessionId}
+ AND "user_id" = ${userId}
+ AND "record_epoch" = ${expectedEpoch}
+ RETURNING "record_epoch"
+ `;
+ const applied = rows[0];
+ if (!applied) return { kind: "stale" };
+ return { kind: "applied", epoch: Number(applied.record_epoch) };
+}
+
/**
* Drop every session of `granteeId` that is currently acting as `grantorId`.
*
diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts
index 9b5dabfc4..0f0b2921e 100644
--- a/src/lib/sharing/record-session-fence.ts
+++ b/src/lib/sharing/record-session-fence.ts
@@ -45,6 +45,7 @@ import {
RECORD_EPOCH_HEADER,
RECORD_SCOPE_HEADER,
parseAssertedContext,
+ recordScopeHeaderValue,
recordSessionFenceVerdict,
type AssertedRecordContext,
} from "@/lib/sharing/record-session-fence-contract";
@@ -76,6 +77,56 @@ export async function readAssertedRecordContext(): Promise;
*/
export const switchAccountSchema = z.object({
accountId: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).nullable(),
+ /**
+ * v1.37.0 — the record epoch the caller believes this session is at.
+ *
+ * Present turns the selector write into a compare-and-set: exactly one of two
+ * tabs pressing the switcher at the same moment lands, and the loser gets a
+ * 409 rather than silently overwriting the winner. It is not a credential —
+ * the epoch is server-issued, monotonic, and scoped to the caller's own
+ * session, so naming it can only ever refuse the caller's own write.
+ *
+ * Absent keeps the pre-fence unconditional write, and that arm is reachable
+ * only by a bundle that predates the fence: the switcher is rendered from
+ * `/api/auth/me` data, so a fence-aware client has necessarily adopted a
+ * context before it can offer a switch at all.
+ */
+ expectedEpoch: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe(
+ "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a previous switch. Present makes the write conditional: if another tab moved the selector first the switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the unconditional write, which is what a client predating the record-session fence sends. Cookie transport only.",
+ ),
});
export type SwitchAccountBody = z.infer;
diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts
index af149f057..13ace25fb 100644
--- a/tests/integration/acting-account-resolver.test.ts
+++ b/tests/integration/acting-account-resolver.test.ts
@@ -181,6 +181,25 @@ async function switchTo(sessionId: string, ownerId: string | null) {
where: { id: sessionId },
data: { actingAsUserId: ownerId },
});
+ await assertCurrentContext(sessionId);
+}
+
+/**
+ * v1.37.0 — attach the record-session assertion the shipped browser client
+ * attaches to every same-origin request.
+ *
+ * Without it every switched case in this file would be exercising the fence's
+ * pre-fence-bundle arm rather than the resolver arm it was written for: a
+ * session pointed at a record is fenced, and a request carrying no assertion is
+ * refused with 403 before the resolver decides anything. The assertion is read
+ * back off the row rather than computed, so it is truthful by construction.
+ */
+async function assertCurrentContext(sessionId: string) {
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: sessionId },
+ });
+ headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch));
+ headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self");
}
beforeEach(async () => {
@@ -415,6 +434,12 @@ describe("acting-account resolver — the carrier column", () => {
expect(row).not.toBeNull();
expect(row!.actingAsUserId).toBeNull();
+ // The deletion also moved the record epoch, so a browser still asserting
+ // the pre-deletion context is refused by the fence — that transition is
+ // proved in `tests/integration/record-session-fence.test.ts`. What THIS
+ // test is about is the column, so it reconciles first (the browser's own
+ // `/api/auth/me` bootstrap) and then reads.
+ await assertCurrentContext(session.id);
const body = await (await call()).json();
expect(body.data.scopeUserId).toBe(delegate.id);
});
diff --git a/tests/integration/record-session-fence.test.ts b/tests/integration/record-session-fence.test.ts
new file mode 100644
index 000000000..881877328
--- /dev/null
+++ b/tests/integration/record-session-fence.test.ts
@@ -0,0 +1,589 @@
+/**
+ * The record-session fence against real Postgres, real routes and the real
+ * resolver.
+ *
+ * ## Why a status code is not the proof
+ *
+ * "The request 409s" passes wherever the fence sits — above the grant lookup,
+ * below it, or after the handler has already read the record. What has to be
+ * proved is that a refused request did NO WORK against the record, and this
+ * file uses the two side effects `resolveSwitchedRecord` performs immediately
+ * after admission as the instrument:
+ *
+ * * `AccountGrant.lastUsedAt` is stamped by `touchGrantUsage`;
+ * * a day-coalesced `audit_logs` row with `action = 'sharing.record.accessed'`
+ * is written by `recordDelegatedAccess`.
+ *
+ * Both run only past admission, so their ABSENCE is the negative instrument and
+ * their PRESENCE is the positive control. They are fire-and-forget, so the
+ * negative assertion is immediate while the positive one polls briefly. Prisma
+ * query logging is not an option here: `src/lib/db.ts` builds the client with no
+ * `log` config, so `prisma.$on("query")` does not exist.
+ *
+ * ## The positive controls, which are half the file
+ *
+ * A fence that refused everything would pass every negative assertion in this
+ * plan. So three exemptions are proved explicitly rather than by absence:
+ * a Bearer request with no fence header is served (FENCE-AC-10), a cookie
+ * session at `record_epoch = 0` with no fence header is served (FENCE-AC-10),
+ * and a matching assertion is served with both side effects present
+ * (FENCE-AC-04).
+ *
+ * Break this file by deleting the `assertRecordSessionFence(auth)` line from
+ * `requireRecordAuth`: the stale leg fails both on the status and on
+ * `lastUsedAt` having moved.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest, NextResponse } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
+import { hashToken } from "@/lib/auth/hmac";
+import { prisma } from "@/lib/db";
+import {
+ RECORD_EPOCH_HEADER,
+ RECORD_FENCE_BOOTSTRAP,
+ RECORD_FENCE_ERROR_CODE,
+ RECORD_SCOPE_HEADER,
+ RECORD_SCOPE_SELF,
+} from "@/lib/sharing/record-session-fence-contract";
+import { pointSessionAtIfUnchanged } from "@/lib/sharing/acting-session";
+import {
+ acceptGrant,
+ inviteGrant,
+ revokeGrant,
+ revokeGrantAndClearSwitch,
+} from "@/lib/sharing/grants";
+
+let counter = 0;
+
+async function makeUser(label: string) {
+ const suffix = `${label}-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `fence-${suffix}`,
+ email: `fence-${suffix}@example.test`,
+ role: "USER",
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return session;
+}
+
+async function mintToken(userId: string): Promise {
+ const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
+ await getPrismaClient().apiToken.create({
+ data: {
+ userId,
+ name: "record-session-fence-test",
+ tokenHash: hashToken(raw),
+ permissions: ["*"],
+ },
+ });
+ return raw;
+}
+
+async function seedWeight(userId: string, value: number) {
+ return getPrismaClient().measurement.create({
+ data: {
+ userId,
+ type: "WEIGHT",
+ value,
+ unit: "kg",
+ measuredAt: new Date(),
+ source: "MANUAL",
+ },
+ });
+}
+
+/**
+ * A delegable read, assembled from the real wrapper and the real resolver so
+ * what is under test is the fence rather than one domain's query shape.
+ */
+const delegableRead: (request: NextRequest) => Promise = apiHandler(
+ async () => {
+ const { user, actor } = await requireRecordAuth("read", "measurements");
+ const rows = await prisma.measurement.findMany({
+ where: { userId: user.id },
+ orderBy: { value: "asc" },
+ });
+ return NextResponse.json({
+ data: {
+ scopeUserId: user.id,
+ actorUserId: actor.id,
+ values: rows.map((r) => r.value),
+ },
+ error: null,
+ });
+ },
+);
+
+/** A delegable WRITE, so a refused mutation can be shown to write nothing. */
+const delegableWrite: (request: NextRequest) => Promise = apiHandler(
+ async () => {
+ const { user } = await requireRecordAuth("write", "measurements");
+ await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "WEIGHT",
+ value: 99,
+ unit: "kg",
+ measuredAt: new Date(),
+ source: "MANUAL",
+ },
+ });
+ return NextResponse.json({ data: { ok: true }, error: null });
+ },
+);
+
+function callRead(): Promise {
+ return delegableRead(new NextRequest("http://localhost/api/test/delegable"));
+}
+
+function callWrite(): Promise {
+ return delegableWrite(
+ new NextRequest("http://localhost/api/test/delegable", { method: "POST" }),
+ );
+}
+
+/** Assert a record context on the next request. */
+function assertContext(epoch: number | string, scope: string | null): void {
+ headerJar.set(RECORD_EPOCH_HEADER, String(epoch));
+ headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF);
+}
+
+async function household(access: "READ" | "WRITE" = "READ") {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ await seedWeight(owner.id, 81);
+ await seedWeight(delegate.id, 64);
+ const invited = await inviteGrant({
+ grantorId: owner.id,
+ granteeId: delegate.id,
+ access,
+ scope: null,
+ });
+ const grant = await acceptGrant({
+ grantId: invited.id,
+ granteeId: delegate.id,
+ });
+ return { owner, delegate, grant };
+}
+
+/**
+ * Point a session at a record OUT OF BAND — the "raw or external switch" of
+ * acceptance case 04. No client journal runs, no BroadcastChannel fires; the
+ * selector simply moves and the epoch follows it.
+ */
+async function switchOutOfBand(sessionId: string, target: string | null) {
+ await getPrismaClient().session.update({
+ where: { id: sessionId },
+ data: { actingAsUserId: target },
+ });
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: sessionId },
+ });
+ return row.recordEpoch;
+}
+
+async function grantLastUsedAt(grantId: string): Promise {
+ const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
+ where: { id: grantId },
+ });
+ return row.lastUsedAt;
+}
+
+async function delegatedAccessRows(ownerId: string, actorId: string) {
+ return getPrismaClient().auditLog.findMany({
+ where: {
+ userId: ownerId,
+ actorUserId: actorId,
+ action: "sharing.record.accessed",
+ },
+ });
+}
+
+/** The two admission side effects are fire-and-forget; give them a moment. */
+async function waitFor(
+ predicate: () => Promise,
+ label: string,
+): Promise {
+ for (let i = 0; i < 40; i++) {
+ if (await predicate()) return;
+ await new Promise((r) => setTimeout(r, 25));
+ }
+ throw new Error(`timed out waiting for ${label}`);
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("FENCE-AC-04 a stale context is refused before any record work", () => {
+ it("FENCE-AC-04 refuses a delegated read whose epoch is behind, touching neither the grant nor the record", async () => {
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ // The tab formed its intent one transition ago.
+ assertContext(epoch - 1, owner.id);
+
+ const res = await callRead();
+ const body = await res.json();
+
+ // The instrument comes FIRST, deliberately. Both writes happen only past
+ // admission, so their absence is the proof that no grant was looked up and
+ // no record was opened — where a status code alone would pass with the
+ // fence sitting anywhere, including after the handler had read the record.
+ // Asserting the status first would mask this one behind it whenever the
+ // fence is removed, which is exactly the case this file exists to catch.
+ // Both writes are fire-and-forget, so the assertion is immediate on
+ // purpose: a fence placed after the grant lookup would already have
+ // stamped them by the time the response was built.
+ expect(await grantLastUsedAt(grant.id)).toBeNull();
+ expect(await delegatedAccessRows(owner.id, delegate.id)).toHaveLength(0);
+
+ expect(res.status).toBe(409);
+ expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
+ });
+
+ it("FENCE-AC-04 refuses a stale delegated WRITE and writes nothing to either record", async () => {
+ const { owner, delegate, grant } = await household("WRITE");
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch - 1, owner.id);
+
+ const res = await callWrite();
+ expect(res.status).toBe(409);
+
+ expect(await grantLastUsedAt(grant.id)).toBeNull();
+ // One seeded row each, and no third row anywhere.
+ const all = await getPrismaClient().measurement.findMany();
+ expect(all).toHaveLength(2);
+ expect(all.some((m) => m.value === 99)).toBe(false);
+ });
+
+ it("FENCE-AC-04 refuses a matching epoch whose SCOPE moved", async () => {
+ // Belt and braces: the epoch alone distinguishes transitions, the scope
+ // catches a selector that moved to a value the client did not expect.
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch, "some-other-account");
+
+ const res = await callRead();
+ expect(res.status).toBe(409);
+ expect(await grantLastUsedAt(grant.id)).toBeNull();
+ });
+
+ it("FENCE-AC-04 POSITIVE CONTROL: a matching assertion is served and both side effects land", async () => {
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch, owner.id);
+
+ const res = await callRead();
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.data.scopeUserId).toBe(owner.id);
+ expect(body.data.actorUserId).toBe(delegate.id);
+ // The owner's 81 kg row and not the delegate's 64 kg one. An empty list
+ // would satisfy a weaker assertion while hiding the failure that matters.
+ expect(body.data.values).toEqual([81]);
+
+ // The response echoes the context it was served under.
+ expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe(String(epoch));
+ expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(owner.id);
+
+ await waitFor(
+ async () => (await grantLastUsedAt(grant.id)) !== null,
+ "AccountGrant.lastUsedAt",
+ );
+ await waitFor(
+ async () => (await delegatedAccessRows(owner.id, delegate.id)).length > 0,
+ "sharing.record.accessed audit row",
+ );
+ });
+});
+
+describe("FENCE-AC-10 the exemptions, proved rather than assumed", () => {
+ it("FENCE-AC-10 serves a Bearer request with no fence header at all", async () => {
+ const { owner, delegate } = await household();
+ const raw = await mintToken(delegate.id);
+ cookieJar.clear();
+ headerJar.set("authorization", `Bearer ${raw}`);
+ headerJar.set("x-healthlog-account", owner.id);
+
+ const res = await callRead();
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.data.scopeUserId).toBe(owner.id);
+ // Nothing is echoed on this transport either: the native contract neither
+ // sends nor receives the fence headers.
+ expect(res.headers.get(RECORD_EPOCH_HEADER)).toBeNull();
+ expect(res.headers.get(RECORD_SCOPE_HEADER)).toBeNull();
+ });
+
+ it("FENCE-AC-10 serves a never-switched cookie session with no fence header at all", async () => {
+ const delegate = await makeUser("solo");
+ await seedWeight(delegate.id, 70);
+ await signIn(delegate.id);
+
+ const res = await callRead();
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.data.values).toEqual([70]);
+ // The exemption still echoes what it served under, so a fence-aware client
+ // has something to validate against from its very first request.
+ expect(res.headers.get(RECORD_EPOCH_HEADER)).toBe("0");
+ expect(res.headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
+ });
+
+ it("FENCE-AC-10 serves a never-switched session that sends the bootstrap sentinel", async () => {
+ const delegate = await makeUser("booting");
+ await seedWeight(delegate.id, 71);
+ await signIn(delegate.id);
+ headerJar.set(RECORD_EPOCH_HEADER, RECORD_FENCE_BOOTSTRAP);
+ headerJar.set(RECORD_SCOPE_HEADER, RECORD_FENCE_BOOTSTRAP);
+
+ const res = await callRead();
+ expect(res.status).toBe(200);
+ });
+
+ it("FENCE-AC-10 refuses a HEADERLESS request on a fenced session with the code a pre-fence bundle recovers from", async () => {
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ await switchOutOfBand(session.id, owner.id);
+ // No fence headers at all — a bundle that predates the fence.
+
+ const res = await callRead();
+ const body = await res.json();
+
+ expect(res.status).toBe(403);
+ // The code the deployed bundle's grant-loss bridge already acts on: it
+ // leaves the record and hard-navigates, which serves the fence-aware
+ // bundle. Handing it the 409 instead would strand it.
+ expect(body.meta.errorCode).toBe("sharing.access.denied");
+ expect(await grantLastUsedAt(grant.id)).toBeNull();
+ });
+
+ it("FENCE-AC-10 keeps a session fenced after it has LEFT a record", async () => {
+ const { owner, delegate } = await household();
+ const session = await signIn(delegate.id);
+ await switchOutOfBand(session.id, owner.id);
+ const epoch = await switchOutOfBand(session.id, null);
+ expect(epoch).toBe(2);
+
+ // A tab still believing it is inside the owner's record.
+ assertContext(1, owner.id);
+ expect((await callRead()).status).toBe(409);
+
+ // And the same session, asserting the truth, is served its own record.
+ assertContext(2, null);
+ const ok = await callRead();
+ expect(ok.status).toBe(200);
+ expect((await ok.json()).data.values).toEqual([64]);
+ });
+});
+
+describe("FENCE-AC-08 concurrent switchers resolve to one monotonic outcome", () => {
+ it("FENCE-AC-08 lets exactly one of two same-epoch switches land", async () => {
+ const { owner, delegate } = await household();
+ const session = await signIn(delegate.id);
+ // Start INSIDE a record, so both competing targets are genuinely distinct
+ // from the current selector. Two tabs racing to a value the row already
+ // holds is not a race at all — the trigger declines to move on a no-op
+ // write and both callers are correctly told their intent stands, which is
+ // the arm asserted separately below.
+ const start = await switchOutOfBand(session.id, owner.id);
+ expect(start).toBe(1);
+
+ // A real second account: the selector carries a foreign key, so a race
+ // between two invented ids would fail on the constraint rather than on the
+ // compare-and-set.
+ const otherOwner = await makeUser("owner-b");
+
+ const [first, second] = await Promise.all([
+ pointSessionAtIfUnchanged(session.id, delegate.id, null, start),
+ pointSessionAtIfUnchanged(session.id, delegate.id, otherOwner.id, start),
+ ]);
+
+ const outcomes = [first.kind, second.kind].sort();
+ expect(outcomes).toEqual(["applied", "stale"]);
+
+ const applied = first.kind === "applied" ? first : second;
+ expect(applied.kind).toBe("applied");
+ if (applied.kind === "applied") {
+ // The epoch handed back is the one the trigger committed, read in the
+ // same statement that committed it.
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: session.id },
+ });
+ expect(applied.epoch).toBe(row.recordEpoch);
+ expect(applied.epoch).toBe(start + 1);
+ }
+ });
+
+ it("FENCE-AC-08 treats a switch to where the session already is as applied, and does not move the epoch", async () => {
+ // The trigger declines a no-op write (`IS DISTINCT FROM`), so the epoch
+ // stands. The caller is still told `applied`, and that is the honest
+ // answer: they asked for the session to be pointed somewhere and it is.
+ // Reporting `stale` here would make a client reconcile against a context
+ // that never changed.
+ const { delegate } = await household();
+ const session = await signIn(delegate.id);
+
+ const outcome = await pointSessionAtIfUnchanged(
+ session.id,
+ delegate.id,
+ null,
+ 0,
+ );
+
+ expect(outcome).toEqual({ kind: "applied", epoch: 0 });
+ });
+
+ it("FENCE-AC-08 refuses a switch that names an epoch the row has already left", async () => {
+ const { owner, delegate } = await household();
+ const session = await signIn(delegate.id);
+ const start = 0;
+
+ const landed = await pointSessionAtIfUnchanged(
+ session.id,
+ delegate.id,
+ owner.id,
+ start,
+ );
+ expect(landed.kind).toBe("applied");
+
+ // A second tab still holding the pre-switch epoch.
+ const stale = await pointSessionAtIfUnchanged(
+ session.id,
+ delegate.id,
+ null,
+ start,
+ );
+ expect(stale.kind).toBe("stale");
+
+ // And the row is where the winner put it, not where the loser wanted it.
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: session.id },
+ });
+ expect(row.actingAsUserId).toBe(owner.id);
+ });
+
+ it("FENCE-AC-08 refuses a switch aimed at another user's session", async () => {
+ // The CAS does not weaken the ownership pin: a matching epoch on somebody
+ // else's session still matches no row.
+ const { owner, delegate } = await household();
+ const stranger = await makeUser("stranger");
+ const session = await signIn(delegate.id);
+
+ const outcome = await pointSessionAtIfUnchanged(
+ session.id,
+ stranger.id,
+ owner.id,
+ 0,
+ );
+ expect(outcome.kind).toBe("stale");
+ });
+});
+
+describe("FENCE-AC-09 the fence refuses after every lifecycle transition", () => {
+ it("FENCE-AC-09 refuses the pre-revocation context after a revoke", async () => {
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch, owner.id);
+ expect((await callRead()).status).toBe(200);
+
+ // The verb the sharing panel calls. It clears the selector inside the same
+ // transaction that stamps the row, so the epoch moves with it and the tab's
+ // context is stale from the very next request.
+ await revokeGrantAndClearSwitch({
+ grantId: grant.id,
+ grantorId: owner.id,
+ });
+
+ const res = await callRead();
+ expect(res.status).toBe(409);
+ expect((await res.json()).meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
+ });
+
+ it("FENCE-AC-09 still refuses on the grant when a revocation leaves the selector in place", async () => {
+ // `revokeGrant` is the bare state transition and clears no session. The
+ // fence is not a substitute for the grant check and must not become one:
+ // the context is still provable, so the fence passes, and the resolver
+ // refuses on the grant exactly as it did before the fence existed.
+ const { owner, delegate, grant } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch, owner.id);
+ expect((await callRead()).status).toBe(200);
+
+ await revokeGrant({ grantId: grant.id, grantorId: owner.id });
+
+ const res = await callRead();
+ expect(res.status).toBe(403);
+ expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
+ });
+
+ it("FENCE-AC-09 refuses the pre-deletion context after the owner account is deleted", async () => {
+ const { owner, delegate } = await household();
+ const session = await signIn(delegate.id);
+ const epoch = await switchOutOfBand(session.id, owner.id);
+
+ assertContext(epoch, owner.id);
+ expect((await callRead()).status).toBe(200);
+
+ // The referential action, which runs no application code. The epoch moves
+ // anyway, because the trigger owns it.
+ await getPrismaClient().user.delete({ where: { id: owner.id } });
+
+ expect((await callRead()).status).toBe(409);
+ });
+});
From 3a99de312e895e38b9bc49a2a65dcc05827b6bab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 18:54:20 +0200
Subject: [PATCH 40/54] fix(02-12.1): check record context before an idempotent
replay
- evaluate the fence in the wrapper above findCached, so a stale request
neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
---
.../acting-account-boundary-guard.test.ts | 12 +-
.../record-session-fence-guard.test.ts | 22 ++
src/lib/__tests__/idempotency.test.ts | 160 ++++++++++
src/lib/auth/acting-carrier.ts | 42 ++-
src/lib/idempotency.ts | 28 +-
src/lib/sharing/record-session-fence.ts | 88 ++++--
.../managed-profile-lifecycle.test.ts | 12 +-
tests/integration/mock-next-headers.ts | 28 ++
.../record-session-fence-idempotency.test.ts | 283 ++++++++++++++++++
tests/integration/setup.ts | 29 ++
.../sharing-account-access.test.ts | 12 +-
tests/integration/sharing-audit-actor.test.ts | 17 +-
.../sharing-delegable-routes.test.ts | 32 +-
.../sharing-delegable-writes.test.ts | 12 +-
tests/integration/sharing-front-door.test.ts | 12 +-
tests/integration/sharing-lifecycle.test.ts | 46 ++-
.../sharing-manage-handler-matrix.test.ts | 12 +-
tests/integration/sharing-scope-fence.test.ts | 12 +-
18 files changed, 731 insertions(+), 128 deletions(-)
create mode 100644 tests/integration/record-session-fence-idempotency.test.ts
diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts
index 42cf7af48..ad7666d33 100644
--- a/src/__tests__/acting-account-boundary-guard.test.ts
+++ b/src/__tests__/acting-account-boundary-guard.test.ts
@@ -139,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => {
* Who may ask "which record does this request claim" without an auth
* context in hand.
*
- * `readClaimedActingAccount()` answers before any grant check has run, so a
+ * `readClaimedRecordContext()` answers before any grant check has run, so a
* caller that treats its answer as permission has skipped the only thing
* that grants any. Exactly one caller exists, beside the file that declares
* it: the idempotency wrapper, which uses the claim to pick a cache cell and
@@ -147,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => {
* acting account has an auth context by then and goes through
* `requireRecordAuth`, which checks a live grant before returning. A second
* caller here is a design question, not a patch.
+ *
+ * The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well
+ * as today's. That function was removed in v1.37.0 when the wrapper started
+ * needing the session's record context on the same read; matching both means
+ * a reintroduced narrow copy trips this leg instead of quietly becoming a
+ * second answer.
*/
it("the unchecked claim has exactly two consumers", () => {
- const consumers = filesMatching(/readClaimedActingAccount/);
+ const consumers = filesMatching(
+ /readClaimedRecordContext|readClaimedActingAccount/,
+ );
expect(consumers.length).toBeGreaterThan(0);
expect(consumers).toEqual([
"lib/auth/acting-carrier.ts",
diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts
index 7d1f6de0d..bb81b3d3f 100644
--- a/src/__tests__/record-session-fence-guard.test.ts
+++ b/src/__tests__/record-session-fence-guard.test.ts
@@ -215,10 +215,32 @@ describe("the epoch has one writer and a closed reader set", () => {
"lib/api-handler.ts",
// Compares it. The one place that decides anything from it.
"lib/sharing/record-session-fence.ts",
+ // Reads it off the same session lookup the idempotency wrapper already
+ // makes, so the fence can be evaluated above the replay cache without a
+ // second round trip.
+ "lib/auth/acting-carrier.ts",
].sort(),
);
});
+ it("is consulted by the idempotency wrapper, above the cache", () => {
+ const src = read("lib/idempotency.ts");
+ const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)");
+ const cache = src.indexOf("findCached(ctx)");
+ const claimRow = src.indexOf("claimKey(ctx)");
+ expect(fence).toBeGreaterThan(-1);
+ expect(cache).toBeGreaterThan(-1);
+ expect(claimRow).toBeGreaterThan(-1);
+ // A stale request must not learn whether a cell exists, replay a body out
+ // of one, or insert a claim row. The runtime proof is the zero-call
+ // instrument in `src/lib/__tests__/idempotency.test.ts`; this is the
+ // tripwire that says the line moved.
+ expect(fence).toBeLessThan(cache);
+ expect(fence).toBeLessThan(claimRow);
+ // And it comes from the fence module, not from a second copy of the rule.
+ expect(src).toContain('from "@/lib/sharing/record-session-fence"');
+ });
+
it("does not leak the raw counter into the browser's vocabulary", () => {
// The client never names the epoch as a column. It receives
// `recordSession: { epoch, scope }` and asserts it back as a header, which
diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts
index eb8d41fae..0476131c5 100644
--- a/src/lib/__tests__/idempotency.test.ts
+++ b/src/lib/__tests__/idempotency.test.ts
@@ -26,6 +26,11 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
+ // v1.37.0 — the record-session fence stamps the response echo on the
+ // request-scoped wide event. There is no event outside a request scope and
+ // the fence handles that with an optional call, so `undefined` is the honest
+ // stub rather than a fake builder.
+ getEvent: vi.fn(() => undefined),
}));
vi.mock("@/lib/auth/session", () => ({
@@ -674,3 +679,158 @@ describe("withIdempotency — record-scoped cells", () => {
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
});
+
+/**
+ * v1.37.0 — the record-session fence, above the replay cache.
+ *
+ * The property is a PLACEMENT, and a status-code assertion cannot see it: a
+ * wrapper that read the cell, replayed nothing, released its claim and then
+ * returned 409 would look identical from outside. `findCached` and `claimKey`
+ * are module-private, so the instrument is one layer lower and falsifiable: the
+ * two Prisma calls they make must receive zero calls for a refused request and
+ * non-zero for a passing one.
+ *
+ * The first draft of this plan proposed asserting "no row exists for the target
+ * cell" instead. That assertion cannot fail: `releaseClaim` deletes the pending
+ * row whenever the handler throws, so it would pass with the fence placed after
+ * `claimKey` too.
+ *
+ * Break it by moving the fence call below `findCached` in `withIdempotency`:
+ * the `findUnique` zero-call assertion fails.
+ */
+describe("the record-session fence sits above the replay cache", () => {
+ function fencedSession(recordEpoch: number, actingAsUserId: string | null) {
+ vi.mocked(getSession).mockResolvedValue({
+ session: {
+ id: "s-1",
+ expiresAt: new Date(Date.now() + 60_000),
+ actingAsUserId,
+ recordEpoch,
+ },
+ user: { id: "u-1" },
+ } as never);
+ }
+
+ function asserts(epoch: string | null, scope: string | null) {
+ vi.mocked(headers).mockResolvedValue({
+ get: vi.fn((name: string) => {
+ if (name.toLowerCase() === "x-healthlog-record-epoch") return epoch;
+ if (name.toLowerCase() === "x-healthlog-record-scope") return scope;
+ return null;
+ }),
+ } as never);
+ }
+
+ it("refuses a stale claim without reading or claiming a cell", async () => {
+ fencedSession(4, "owner-1");
+ asserts("3", "owner-1");
+ const handler = vi.fn(async () =>
+ NextResponse.json({ data: "ok", error: null }, { status: 201 }),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler);
+
+ const res = await wrapped(
+ makeRequest("POST", { "idempotency-key": "abc-12345678" }),
+ );
+
+ expect(res.status).toBe(409);
+ expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
+ // The placement, stated as the only thing that can prove it.
+ expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
+ expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
+ // And the side effect the whole wrapper exists to run once never ran.
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("POSITIVE CONTROL: a matching claim reads the cell and claims it", async () => {
+ // Without this the assertion above would pass for a wrapper that never
+ // touched the cache at all.
+ fencedSession(4, "owner-1");
+ asserts("4", "owner-1");
+ const handler = vi.fn(async () =>
+ NextResponse.json({ data: "ok", error: null }, { status: 201 }),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler);
+
+ const res = await wrapped(
+ makeRequest("POST", { "idempotency-key": "abc-12345678" }),
+ );
+
+ expect(res.status).toBe(201);
+ expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
+ expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("lets an unfenced client through to the route's own refusal", async () => {
+ // `unfenced-client` is deliberately NOT refused here: the route owns the
+ // 403 a pre-fence bundle recovers from, together with its annotation and
+ // audit posture. The wrapper still files the cell, because the handler is
+ // about to answer.
+ fencedSession(4, "owner-1");
+ asserts(null, null);
+ const handler = vi.fn(async () =>
+ NextResponse.json(
+ { data: null, error: "Account access denied" },
+ { status: 403 },
+ ),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler);
+
+ const res = await wrapped(
+ makeRequest("POST", { "idempotency-key": "abc-12345678" }),
+ );
+
+ expect(res.status).toBe(403);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves a never-switched session byte-identical", async () => {
+ fencedSession(0, null);
+ asserts(null, null);
+ const handler = vi.fn(async () =>
+ NextResponse.json({ data: "ok", error: null }, { status: 201 }),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler);
+
+ const res = await wrapped(
+ makeRequest("POST", { "idempotency-key": "abc-12345678" }),
+ );
+
+ expect(res.status).toBe(201);
+ expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
+ expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaves the Bearer transport byte-identical", async () => {
+ // No session row at all, so no context to be stale about — and a fence
+ // header on this transport is neither read nor honoured.
+ vi.mocked(getSession).mockResolvedValue(null);
+ vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
+ userId: "u-bearer",
+ revoked: false,
+ expiresAt: null,
+ } as never);
+ vi.mocked(headers).mockResolvedValue({
+ get: vi.fn((name: string) => {
+ if (name.toLowerCase() === "authorization")
+ return `Bearer hlk_${"a".repeat(64)}`;
+ if (name.toLowerCase() === "x-healthlog-record-epoch") return "1";
+ if (name.toLowerCase() === "x-healthlog-record-scope") return "owner-1";
+ return null;
+ }),
+ } as never);
+ const handler = vi.fn(async () =>
+ NextResponse.json({ data: "ok", error: null }, { status: 201 }),
+ );
+ const wrapped = withIdempotency<[NextRequest]>(handler);
+
+ const res = await wrapped(
+ makeRequest("POST", { "idempotency-key": "abc-12345678" }),
+ );
+
+ expect(res.status).toBe(201);
+ expect(prisma.idempotencyKey.findUnique).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts
index 055a708ec..851ab7193 100644
--- a/src/lib/auth/acting-carrier.ts
+++ b/src/lib/auth/acting-carrier.ts
@@ -191,9 +191,33 @@ export async function getUnswitchedSession(): Promise {
+/**
+ * v1.37.0 — and, on the same read, what that request's session actually holds.
+ *
+ * This replaced a narrower `readClaimedActingAccount()` that returned only the
+ * claim. The idempotency wrapper — still the one caller — now needs a second
+ * fact: the session's own record context, so the record-session fence can be
+ * evaluated above the replay cache. Widening the existing function rather than
+ * adding a second one is what keeps that from costing a second `getSession()`
+ * on every idempotent write, and keeps "what does this request say it is acting
+ * as" to one implementation, which is the thing this file exists to ensure.
+ *
+ * The narrow function is gone rather than kept as a shim: it had no caller left,
+ * and a wrapper nobody calls is a second answer waiting for somebody to reach
+ * for it.
+ */
+export interface ClaimedRecordContext {
+ /** The claimed record, or `undefined` when the carrier was unsafe to resolve. */
+ claimedRecord: string | null | undefined;
+ /** Which credential the request authenticated with. */
+ transport: "cookie" | "bearer";
+ /** The session's selector-move counter. Always 0 on the Bearer transport. */
+ sessionEpoch: number;
+ /** The account the session is pointed at, or null. Always null on Bearer. */
+ sessionScope: string | null;
+}
+
+export async function readClaimedRecordContext(): Promise {
const header = await readSelectorHeader();
let session: Awaited> = null;
try {
@@ -204,11 +228,17 @@ export async function readClaimedActingAccount(): Promise<
// unauthenticated request claims nothing.
session = null;
}
+ const transport: "cookie" | "bearer" = session ? "cookie" : "bearer";
const carrier = decideActingCarrier({
- transport: session ? "cookie" : "bearer",
+ transport,
stamped: session?.session.actingAsUserId ?? null,
header,
});
- if (carrier.kind === "misplaced-header") return undefined;
- return carrierTarget(carrier);
+ return {
+ claimedRecord:
+ carrier.kind === "misplaced-header" ? undefined : carrierTarget(carrier),
+ transport,
+ sessionEpoch: session?.session.recordEpoch ?? 0,
+ sessionScope: session?.session.actingAsUserId ?? null,
+ };
}
diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts
index 28fb170a1..40186739f 100644
--- a/src/lib/idempotency.ts
+++ b/src/lib/idempotency.ts
@@ -18,9 +18,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
- readClaimedActingAccount,
+ readClaimedRecordContext,
selectorNamesAnAccount,
} from "@/lib/auth/acting-carrier";
+import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fence";
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
@@ -327,7 +328,7 @@ export function isCachableStatus(status: number): boolean {
* the handler again and creating duplicate measurements (audit C-4).
*
* It resolves the CALLER and only the caller — which record they are aiming at
- * is a separate question, answered by `readClaimedActingAccount()` above and
+ * is a separate question, answered by `readClaimedRecordContext()` above and
* folded into the key. Neither answer authorises anything: the handler runs
* its own `requireAuth()` / `requireRecordAuth()` either way.
*
@@ -456,7 +457,28 @@ export function withIdempotency<
// A claim only chooses the cache cell. It never carries authority: a
// completed delegated cell is checked against the current grant again
// immediately before its body can be returned below.
- const claimedRecord = await readClaimedActingAccount();
+ const claim = await readClaimedRecordContext();
+
+ // v1.37.0 — the record-session fence, evaluated HERE and not one line
+ // lower. A stale request must not learn whether a cell exists, must not
+ // replay a body out of one, and must not insert a claim row — so the
+ // verdict sits above `findCached` and above the two carrier arms below,
+ // which choose the cell. It is the second, independent evaluation of the
+ // same client assertion (the handler's own `requireRecordAuth` is the
+ // other) and nothing is carried between them; see the note on
+ // `refuseStaleRecordSessionClaim`.
+ //
+ // Only `stale` refuses here. An `unfenced-client` verdict falls through so
+ // the route issues the 403 a pre-fence bundle already recovers from, with
+ // the annotation and audit posture that belong to the route.
+ //
+ // A never-switched session and every Bearer request are untouched: both
+ // pass the verdict without a header, which is what keeps every existing
+ // own-record and delegated-cell contract byte-identical.
+ const staleContext = await refuseStaleRecordSessionClaim(claim);
+ if (staleContext) return staleContext;
+
+ const claimedRecord = claim.claimedRecord;
if (claimedRecord === undefined) {
// The carrier cannot safely name a record. In particular, a selector on
// a cookie request is a misplaced claim, not an own-record request.
diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts
index 0f0b2921e..61ab2cdb8 100644
--- a/src/lib/sharing/record-session-fence.ts
+++ b/src/lib/sharing/record-session-fence.ts
@@ -40,9 +40,11 @@
* discards a response whose echoed context contradicts the one it is now on.
*/
import { headers } from "next/headers";
+import { NextResponse } from "next/server";
import {
RECORD_EPOCH_HEADER,
+ RECORD_FENCE_ERROR_CODE,
RECORD_SCOPE_HEADER,
parseAssertedContext,
recordScopeHeaderValue,
@@ -143,23 +145,10 @@ export function recordSessionForPayload(
export async function assertRecordSessionFence(
auth: AuthContext,
): Promise {
- const transport = auth.authMethod === "bearer" ? "bearer" : "cookie";
- const sessionEpoch = auth.session.recordEpoch ?? 0;
- const sessionScope = auth.session.actingAsUserId ?? null;
-
- // Stamped before the verdict, so a refusal echoes the truth too. The Bearer
- // transport carries no session context to echo and gets none: the frozen
- // native contract sends no fence header and receives none back.
- if (transport === "cookie") {
- getEvent()?.setRecordContext({ epoch: sessionEpoch, scope: sessionScope });
- }
-
- const asserted = await readAssertedRecordContext();
- const verdict = recordSessionFenceVerdict({
- transport,
- sessionEpoch,
- sessionScope,
- asserted,
+ const verdict = await decide({
+ transport: auth.authMethod === "bearer" ? "bearer" : "cookie",
+ sessionEpoch: auth.session.recordEpoch ?? 0,
+ sessionScope: auth.session.actingAsUserId ?? null,
});
if (verdict === "pass") return;
@@ -183,3 +172,68 @@ export async function assertRecordSessionFence(
annotate({ meta: { sharing_refusal: "unfenced_client" } });
throw new SharingAccessDeniedError();
}
+
+/**
+ * The same verdict, for the idempotency wrapper, returning bytes instead of
+ * throwing.
+ *
+ * `withIdempotency` is composed OUTSIDE `apiHandler` on at least one route
+ * (`POST /api/admin/backups/[id]/restore`), so a throw from the wrapper would
+ * escape the envelope and become a 500 rather than the documented refusal. It
+ * therefore returns a response, hand-built to the same
+ * `{ data, error, meta.errorCode }` shape `apiHandler`'s `SharingAuthError` arm
+ * emits, so a client cannot tell which layer refused it.
+ *
+ * Only the `stale` arm produces a response. `unfenced-client` deliberately
+ * falls through to the handler, whose `requireRecordAuth` issues the 403 a
+ * pre-fence bundle recovers from — along with the wide-event annotation and the
+ * audit posture that belong to the route, not to a cache wrapper.
+ *
+ * This is the SECOND, INDEPENDENT evaluation of the fence on such a request,
+ * and its result is deliberately NOT carried into the handler's own. Carrying
+ * it would need request-scoped mutable state shared across the wrapper/handler
+ * boundary, which is a second source of truth for the same question. Two
+ * independent evaluations against the same client assertion are sufficient: a
+ * switch landing between the two fails the second, so no handler body runs
+ * under a scope the asserted epoch does not imply.
+ */
+export async function refuseStaleRecordSessionClaim(
+ claim: RecordSessionInputs,
+): Promise {
+ const verdict = await decide(claim);
+ if (verdict !== "stale") return null;
+ annotate({ meta: { sharing_refusal: "record_context_stale_idempotent" } });
+ return NextResponse.json(
+ {
+ data: null,
+ error: "The record this session is on has changed",
+ meta: { errorCode: RECORD_FENCE_ERROR_CODE },
+ },
+ { status: 409 },
+ );
+}
+
+/** What the fence needs to know about the session, from either caller. */
+interface RecordSessionInputs {
+ transport: "cookie" | "bearer";
+ sessionEpoch: number;
+ sessionScope: string | null;
+}
+
+/** Stamp the echo and read the verdict. The one place either happens. */
+async function decide(input: RecordSessionInputs) {
+ // Stamped before the verdict, so a refusal echoes the truth too. The Bearer
+ // transport carries no session context to echo and gets none: the frozen
+ // native contract sends no fence header and receives none back.
+ if (input.transport === "cookie") {
+ getEvent()?.setRecordContext({
+ epoch: input.sessionEpoch,
+ scope: input.sessionScope,
+ });
+ }
+
+ return recordSessionFenceVerdict({
+ ...input,
+ asserted: await readAssertedRecordContext(),
+ });
+}
diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts
index 39c7219e2..0f7e14872 100644
--- a/tests/integration/managed-profile-lifecycle.test.ts
+++ b/tests/integration/managed-profile-lifecycle.test.ts
@@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import {
acceptGrant,
@@ -324,10 +324,7 @@ describe("managed profile lifecycle (real Postgres)", () => {
where: { id: invitation.id },
}),
).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null });
- await getPrismaClient().session.update({
- where: { id: secondGuardian.sessionId },
- data: { actingAsUserId: profile.id },
- });
+ await switchSessionTo(secondGuardian.sessionId, profile.id);
cookieJar.set("healthlog_session", creator.sessionId);
const { DELETE: revokeGuardian } =
@@ -505,10 +502,7 @@ describe("managed profile lifecycle (real Postgres)", () => {
const { creator, profile, secondGuardian, secondGrant } =
await createTwoGuardians();
cookieJar.set("healthlog_session", creator.sessionId);
- await getPrismaClient().session.update({
- where: { id: secondGuardian.sessionId },
- data: { actingAsUserId: profile.id },
- });
+ await switchSessionTo(secondGuardian.sessionId, profile.id);
const prisma = getPrismaClient();
await prisma.$executeRawUnsafe(
"CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$",
diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts
index edce8a7cc..f6de28c2e 100644
--- a/tests/integration/mock-next-headers.ts
+++ b/tests/integration/mock-next-headers.ts
@@ -24,3 +24,31 @@ export const headerJar = new Map();
* A request consumes one entry when its mocked cookie store is created.
*/
export const queuedSessionIds: string[] = [];
+
+/**
+ * v1.37.0 — attach the record-session assertion the shipped browser client
+ * attaches to every same-origin request.
+ *
+ * The record-session fence refuses a cookie request whose asserted context does
+ * not match its session row, and it refuses a request that asserts nothing at
+ * all once the session has been inside somebody's record. A test that moves
+ * `acting_as_user_id` directly and then calls a route is therefore exercising
+ * the fence's pre-fence-bundle arm, not whatever the test was written for — so
+ * every such fixture calls this immediately after the switch.
+ *
+ * Truthful by construction: the values are read back off the row rather than
+ * computed, so this can only ever say what the session actually holds. The
+ * cases that want a STALE or ABSENT assertion set the headers themselves, and
+ * they live in `tests/integration/record-session-fence*.test.ts`.
+ *
+ * Takes the epoch and scope rather than reading the database, so this module
+ * keeps its single responsibility (shared mock state) and no test file has to
+ * import a Prisma client it does not otherwise use.
+ */
+export function assertRecordContext(
+ recordEpoch: number,
+ actingAsUserId: string | null,
+): void {
+ headerJar.set("x-healthlog-record-epoch", String(recordEpoch));
+ headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self");
+}
diff --git a/tests/integration/record-session-fence-idempotency.test.ts b/tests/integration/record-session-fence-idempotency.test.ts
new file mode 100644
index 000000000..e241671ae
--- /dev/null
+++ b/tests/integration/record-session-fence-idempotency.test.ts
@@ -0,0 +1,283 @@
+/**
+ * FENCE-AC-05 — an idempotent write whose record context moved between the
+ * client forming its intent and the server receiving it.
+ *
+ * The dangerous shape is specific. A tab posts a mutation to the owner's record
+ * with an `Idempotency-Key`, the selector moves out of band, and the retry
+ * arrives. Without a fence above the cache the retry either replays the owner's
+ * cached body under the new context, or files a fresh claim in a cell it has no
+ * business choosing. Either way nothing errors.
+ *
+ * ## What is asserted, and what deliberately is not
+ *
+ * The seeded owner cell is asserted BYTE-UNCHANGED — same id, same
+ * `responseStatus`, same `responseBody`, same `expiresAt` — because that is
+ * falsifiable. `expiresAt` carries the weight: the completion path rewrites it
+ * to `now + 24h` and a fresh claim rewrites it to `now + 2min`, so a wrapper
+ * that reached the cache at all would move it. The obvious alternative,
+ * "no row exists for the target cell", is not: `releaseClaim` deletes the
+ * pending row whenever the handler throws, so that assertion passes with the
+ * fence placed after `claimKey` too. The zero-call proof for `findUnique` /
+ * `create` lives one layer down in `src/lib/__tests__/idempotency.test.ts`,
+ * where the Prisma calls can be spied on directly.
+ *
+ * Everything under the test is real: the shipped wrapper, the shipped resolver,
+ * the composite unique index, the trigger that moves the epoch.
+ */
+import { NextRequest, NextResponse } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
+import { prisma } from "@/lib/db";
+import { withIdempotency } from "@/lib/idempotency";
+import {
+ RECORD_EPOCH_HEADER,
+ RECORD_FENCE_ERROR_CODE,
+ RECORD_SCOPE_HEADER,
+ RECORD_SCOPE_SELF,
+} from "@/lib/sharing/record-session-fence-contract";
+
+const KEY = "retry-7c21ab04";
+
+let counter = 0;
+let handlerRuns = 0;
+
+async function makeUser(label: string) {
+ const suffix = `${label}-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `fence-idem-${suffix}`,
+ email: `fence-idem-${suffix}@example.test`,
+ role: "USER",
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return session;
+}
+
+async function writeGrant(grantorId: string, granteeId: string) {
+ return getPrismaClient().accountGrant.create({
+ data: {
+ grantorId,
+ granteeId,
+ access: "WRITE",
+ acceptedAt: new Date(Date.now() - 60_000),
+ },
+ });
+}
+
+/** The shipped composition: `apiHandler(withIdempotency(handler))`. */
+const delegableWrite: (request: NextRequest) => Promise = apiHandler(
+ withIdempotency<[NextRequest]>(async () => {
+ const { user } = await requireRecordAuth("write", "measurements");
+ // Counted AFTER the resolver, so it means "the handler body ran" rather
+ // than "the wrapper called through". The wrapper deliberately calls
+ // through on an `unfenced-client` verdict so the route can issue its own
+ // refusal, and a counter incremented above this line would report that
+ // pass-through as a side effect having happened.
+ handlerRuns += 1;
+ const row = await prisma.measurement.create({
+ data: {
+ userId: user.id,
+ type: "WEIGHT",
+ value: 80 + handlerRuns,
+ unit: "kg",
+ measuredAt: new Date(),
+ source: "MANUAL",
+ },
+ });
+ return NextResponse.json(
+ { data: { id: row.id, userId: row.userId }, error: null },
+ { status: 201 },
+ );
+ }),
+);
+
+function post(): Promise {
+ return delegableWrite(
+ new NextRequest("http://localhost/api/test/delegable-write", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "idempotency-key": KEY,
+ },
+ body: JSON.stringify({ value: 81 }),
+ }),
+ );
+}
+
+function assertContext(epoch: number, scope: string | null): void {
+ headerJar.set(RECORD_EPOCH_HEADER, String(epoch));
+ headerJar.set(RECORD_SCOPE_HEADER, scope ?? RECORD_SCOPE_SELF);
+}
+
+async function switchOutOfBand(sessionId: string, target: string | null) {
+ await getPrismaClient().session.update({
+ where: { id: sessionId },
+ data: { actingAsUserId: target },
+ });
+ const row = await getPrismaClient().session.findUniqueOrThrow({
+ where: { id: sessionId },
+ });
+ return row.recordEpoch;
+}
+
+async function cells() {
+ return getPrismaClient().idempotencyKey.findMany({ orderBy: { key: "asc" } });
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ handlerRuns = 0;
+});
+
+describe("FENCE-AC-05 an idempotent write under a moved record context", () => {
+ it("FENCE-AC-05 refuses the replay, leaves the seeded owner cell byte-unchanged, and writes to neither record", async () => {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ await writeGrant(owner.id, delegate.id);
+ const session = await signIn(delegate.id);
+
+ // A completed owner cell for key K, written by the real path.
+ const inRecord = await switchOutOfBand(session.id, owner.id);
+ assertContext(inRecord, owner.id);
+ const first = await post();
+ expect(first.status).toBe(201);
+ expect(handlerRuns).toBe(1);
+
+ const seeded = await cells();
+ expect(seeded).toHaveLength(1);
+ const before = seeded[0];
+ expect(before.responseStatus).toBe(201);
+
+ // The selector moves out of band. The tab knows nothing about it and
+ // retries the same key under the context it formed its intent in.
+ const afterSwitch = await switchOutOfBand(session.id, null);
+ expect(afterSwitch).toBe(inRecord + 1);
+
+ const replay = await post();
+ const body = await replay.json();
+
+ // (a) refused
+ expect(replay.status).toBe(409);
+ expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
+
+ // (b) the seeded cell is untouched — nothing read it, claimed it, or
+ // rewrote it. `updatedAt` is the falsifiable half: a wrapper that reached
+ // the cache and released a claim would leave the row present but moved.
+ const after = await cells();
+ expect(after).toHaveLength(1);
+ expect(after[0].id).toBe(before.id);
+ expect(after[0].key).toBe(before.key);
+ expect(after[0].responseStatus).toBe(before.responseStatus);
+ expect(after[0].responseBody).toBe(before.responseBody);
+ expect(after[0].expiresAt.getTime()).toBe(before.expiresAt.getTime());
+ expect(after[0].createdAt.getTime()).toBe(before.createdAt.getTime());
+
+ // (c) no write landed on either record: one row from the first request and
+ // nothing since, on either side.
+ expect(handlerRuns).toBe(1);
+ const rows = await getPrismaClient().measurement.findMany();
+ expect(rows).toHaveLength(1);
+ expect(rows[0].userId).toBe(owner.id);
+ });
+
+ it("FENCE-AC-05 POSITIVE CONTROL: the same replay under a matching context returns the cached body", async () => {
+ // Without this, the assertions above would pass for a wrapper that had
+ // stopped replaying anything at all.
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ await writeGrant(owner.id, delegate.id);
+ const session = await signIn(delegate.id);
+
+ const inRecord = await switchOutOfBand(session.id, owner.id);
+ assertContext(inRecord, owner.id);
+
+ const first = await post();
+ expect(first.status).toBe(201);
+ const firstBody = await first.json();
+
+ const replay = await post();
+ const replayBody = await replay.json();
+
+ expect(replay.status).toBe(201);
+ expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
+ expect(replayBody.data.id).toBe(firstBody.data.id);
+ // Replayed, not re-run.
+ expect(handlerRuns).toBe(1);
+ expect(await getPrismaClient().measurement.findMany()).toHaveLength(1);
+ });
+
+ it("FENCE-AC-05 leaves a never-switched idempotent write byte-identical", async () => {
+ // No fence header at all, no switch ever. This is the shape every existing
+ // own-record idempotency contract has, and it must not have moved.
+ const solo = await makeUser("solo");
+ await signIn(solo.id);
+
+ const first = await post();
+ expect(first.status).toBe(201);
+ const replay = await post();
+
+ expect(replay.status).toBe(201);
+ expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
+ expect(handlerRuns).toBe(1);
+ const stored = await cells();
+ expect(stored).toHaveLength(1);
+ // The un-delegated cell files under the client's key verbatim — the record
+ // fold does not touch a request that is nobody's delegate.
+ expect(stored[0].key).toBe(KEY);
+ });
+
+ it("FENCE-AC-05 lets a pre-fence bundle reach the route's own refusal", async () => {
+ // An `unfenced-client` verdict is not the wrapper's to refuse: the route
+ // owns the 403 that bundle recovers from.
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ await writeGrant(owner.id, delegate.id);
+ const session = await signIn(delegate.id);
+ await switchOutOfBand(session.id, owner.id);
+ // No fence headers at all.
+
+ const res = await post();
+ expect(res.status).toBe(403);
+ expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
+ expect(handlerRuns).toBe(0);
+ expect(await getPrismaClient().measurement.findMany()).toHaveLength(0);
+ });
+});
diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts
index 54e0cdebb..3430ad2e6 100644
--- a/tests/integration/setup.ts
+++ b/tests/integration/setup.ts
@@ -4,6 +4,7 @@
* environment-setup.ts before application modules load.
*/
import { prisma } from "@/lib/db";
+import { assertRecordContext } from "./mock-next-headers";
import type { PrismaClient } from "@/generated/prisma/client";
/**
@@ -118,3 +119,31 @@ export async function truncateAllTables(client: PrismaClient): Promise {
});
}
}
+
+/**
+ * v1.37.0 — point a session at a record the way a test needs it pointed, and
+ * leave the request headers agreeing with the row.
+ *
+ * Every sharing fixture used to do this with a bare `session.update`. That is
+ * still the write, but since the record-session fence a cookie request also has
+ * to ASSERT the context it believes it is in: a session pointed at a record and
+ * a request carrying no assertion is a bundle that predates the fence, and it is
+ * refused with 403 before any resolver decides anything. A fixture that switched
+ * and did not assert would therefore be testing the deploy-compatibility arm
+ * rather than whatever it was written for.
+ *
+ * The assertion is read back off the row rather than computed, so it is
+ * truthful by construction and cannot drift from the trigger's idea of the
+ * epoch. Tests that WANT a stale or absent assertion set the headers themselves;
+ * those live in `tests/integration/record-session-fence*.test.ts`.
+ */
+export async function switchSessionTo(
+ sessionId: string,
+ actingAsUserId: string | null,
+): Promise {
+ const row = await prisma.session.update({
+ where: { id: sessionId },
+ data: { actingAsUserId },
+ });
+ assertRecordContext(row.recordEpoch, row.actingAsUserId);
+}
diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts
index c66aac061..bb0daef58 100644
--- a/tests/integration/sharing-account-access.test.ts
+++ b/tests/integration/sharing-account-access.test.ts
@@ -17,7 +17,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -298,10 +298,7 @@ describe("accountAccess — the active record", () => {
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
const { accountAccess, id } = await readMe();
@@ -327,10 +324,7 @@ describe("accountAccess — the active record", () => {
const delegate = await makeUser("delegate");
const grant = await grantAccess(owner.id, delegate.id);
const session = await signIn(delegate.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
// The grant lapses while the browser sits inside the record. Expiry has
// no session cleanup — only revocation does — so the stamp survives it.
await getPrismaClient().accountGrant.update({
diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts
index 8c0a871c2..80cc88377 100644
--- a/tests/integration/sharing-audit-actor.test.ts
+++ b/tests/integration/sharing-audit-actor.test.ts
@@ -17,7 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -103,10 +103,7 @@ async function household() {
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
return { owner, delegate, session };
}
@@ -185,10 +182,7 @@ describe("who the trail says did it", () => {
const delegate = await makeUser("delegate");
const stranger = await makeUser("stranger");
const session = await signIn(delegate.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: stranger.id },
- });
+ await switchSessionTo(session.id, stranger.id);
expect((await act()).status).toBe(403);
await settle();
@@ -232,10 +226,7 @@ describe("delegated reads coalesce to one row a day", () => {
await act();
const secondSession = await signIn(second.id);
- await getPrismaClient().session.update({
- where: { id: secondSession.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(secondSession.id, owner.id);
await act();
await settle();
diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts
index 8e7c44c92..1c4e95ee7 100644
--- a/tests/integration/sharing-delegable-routes.test.ts
+++ b/tests/integration/sharing-delegable-routes.test.ts
@@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -99,10 +99,7 @@ async function switchInto(ownerId: string, delegateId: string) {
},
});
const session = await signIn(delegateId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
return { grant, session };
}
@@ -199,10 +196,7 @@ function contract(
await route.seed(owner.id, route.ownerMarker);
const session = await signIn(stranger.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
await expectDenied(await route.call());
});
@@ -1108,10 +1102,7 @@ describe("GET /api/sleep/night", () => {
const owner = await makeUser("owner");
const stranger = await makeUser("stranger");
const session = await signIn(stranger.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
const { GET } = await import("@/app/api/sleep/night/route");
await expectDenied(await call(GET as Handler, "/api/sleep/night"));
@@ -1550,10 +1541,7 @@ describe("GET /api/medications/intake", () => {
await seedTodaysDose(owner.id, "owner-statin");
const session = await signIn(stranger.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
await expectDenied(await today());
});
@@ -1682,10 +1670,7 @@ describe("the per-medication reads", () => {
});
const session = await signIn(stranger.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
const detail = await import("@/app/api/medications/[id]/route");
await expectDenied(
@@ -1882,10 +1867,7 @@ describe("GET /api/documents/inbound", () => {
},
});
const session = await signIn(delegate.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
expect(grant.access).toBe("WRITE");
const { POST } = await import("@/app/api/documents/inbound/route");
diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts
index 97dcb51de..c3ac2452c 100644
--- a/tests/integration/sharing-delegable-writes.test.ts
+++ b/tests/integration/sharing-delegable-writes.test.ts
@@ -39,7 +39,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -134,20 +134,14 @@ async function switchInto(
expect(grant.acceptedAt).not.toBeNull();
const session = await signIn(delegateId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
return { grant, session };
}
/** A session inside a record nobody ever shared with this caller. */
async function claimWithoutGrant(ownerId: string, callerId: string) {
const session = await signIn(callerId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
}
async function revoke(grantId: string, ownerId: string) {
diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts
index 90e819089..36c71d62d 100644
--- a/tests/integration/sharing-front-door.test.ts
+++ b/tests/integration/sharing-front-door.test.ts
@@ -32,7 +32,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -105,10 +105,7 @@ async function switchInto(
granteeId: delegateId,
});
const session = await signIn(delegateId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
return { grant, session };
}
@@ -242,10 +239,7 @@ function frontDoorRead(name: string, route: FrontDoorRead) {
await route.seed(owner.id, "owner");
const session = await signIn(stranger.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
await expectAccessDenied(await route.call());
});
diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts
index 2d835d0f9..beeec56d8 100644
--- a/tests/integration/sharing-lifecycle.test.ts
+++ b/tests/integration/sharing-lifecycle.test.ts
@@ -20,8 +20,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
-import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -71,6 +71,11 @@ async function signIn(userId: string) {
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
+ // A new session is a new browser, and a new browser has adopted nothing. The
+ // reset matters where one test drives two of them: a stale adopted epoch
+ // carried across would make the second browser's first switch lose a
+ // compare-and-set it was never in.
+ adoptedEpoch = 0;
return session;
}
@@ -147,13 +152,39 @@ async function renounce(grantId: string): Promise {
);
}
+/**
+ * Switch the way the shipped browser switches: name the epoch the client
+ * believes it is on, and ADOPT the context the response publishes.
+ *
+ * v1.37.0 — the adoption is not decoration. `POST /api/account/switch` and
+ * `GET /api/auth/me` are the only two responses a client may learn its record
+ * context from, and every same-origin request after a switch has to assert it
+ * or the record-session fence refuses. A fixture that switched and did not
+ * adopt would be testing the fence's pre-fence-bundle arm rather than the
+ * handshake this file is about — so the round trip is exercised here, which is
+ * the property the feature actually ships.
+ */
async function switchTo(accountId: string | null): Promise {
const { POST } = await import("@/app/api/account/switch/route");
- return POST(
- jsonRequest("http://localhost/api/account/switch", "POST", { accountId }),
+ const response = await POST(
+ jsonRequest("http://localhost/api/account/switch", "POST", {
+ accountId,
+ expectedEpoch: adoptedEpoch,
+ }),
);
+ if (response.status === 200) {
+ const { recordSession } = (await response.clone().json()).data;
+ if (recordSession) {
+ adoptedEpoch = recordSession.epoch;
+ assertRecordContext(recordSession.epoch, recordSession.scope);
+ }
+ }
+ return response;
}
+/** The epoch this fixture's browser currently believes it is on. */
+let adoptedEpoch = 0;
+
/** A delegable read, exactly as a migrated route builds one. */
const delegableRead: (request: NextRequest) => Promise = apiHandler(
async () => {
@@ -205,6 +236,8 @@ beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
+ // Every test starts on a fresh browser that has adopted nothing.
+ adoptedEpoch = 0;
});
describe("the handshake, end to end", () => {
@@ -276,10 +309,7 @@ describe("the handshake, end to end", () => {
// And the delegate's next request is refused on the grant, independently
// of the session having been cleared.
cookieJar.set("healthlog_session", delegateSession.id);
- await getPrismaClient().session.update({
- where: { id: delegateSession.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(delegateSession.id, owner.id);
const afterRevoke = await readRecord();
expect(afterRevoke.status).toBe(403);
expect((await afterRevoke.json()).meta.errorCode).toBe(
diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts
index fb55951df..05cfca8c1 100644
--- a/tests/integration/sharing-manage-handler-matrix.test.ts
+++ b/tests/integration/sharing-manage-handler-matrix.test.ts
@@ -42,7 +42,7 @@ import {
type MutatingHandlerContract,
} from "../fixtures/v137/sharing-matrix";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
@@ -140,20 +140,14 @@ async function switchInto(
expect(grant.acceptedAt).not.toBeNull();
const session = await signIn(delegateId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
return { grant, session };
}
/** A session inside a record nobody ever shared with this caller. */
async function claimWithoutGrant(ownerId: string, callerId: string) {
const session = await signIn(callerId);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
}
type Handler = (
diff --git a/tests/integration/sharing-scope-fence.test.ts b/tests/integration/sharing-scope-fence.test.ts
index 1b489b8cb..17848c2fe 100644
--- a/tests/integration/sharing-scope-fence.test.ts
+++ b/tests/integration/sharing-scope-fence.test.ts
@@ -30,7 +30,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
-import { getPrismaClient, truncateAllTables } from "./setup";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import type { ShareDomain } from "@/lib/sharing/scope";
@@ -117,10 +117,7 @@ async function switchInto(
});
const session = await signIn(delegateId);
cookieJar.set("healthlog_session", session.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: ownerId },
- });
+ await switchSessionTo(session.id, ownerId);
return grant;
}
@@ -393,10 +390,7 @@ describe("the refusal does not distinguish itself", () => {
const stranger = await makeUser("stranger");
const session = await signIn(stranger.id);
cookieJar.set("healthlog_session", session.id);
- await getPrismaClient().session.update({
- where: { id: session.id },
- data: { actingAsUserId: owner.id },
- });
+ await switchSessionTo(session.id, owner.id);
const noGrant = await PROBES.labs.call();
expect(outOfScope.status).toBe(noGrant.status);
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 41/54] 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,
From 8fcbdcc0dfccdcd36c5486f0bfffe8fb597f6cc4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 19:33:15 +0200
Subject: [PATCH 42/54] test(02-12.1): prove the record-session fence end to
end
- give every acceptance case a stable id and assert the mapping from a
runnable artifact, recording which cases execute in the ordinary gate
and which await the browser stage
- add the browser spec for the cases no cooperative layer can reach:
peers reconciling to an exact context, an aborted switch response, a
closed initiator, a delayed response, and the offline disk layers
---
e2e/v137-record-session-fence.spec.ts | 449 ++++++++++++++++++
...ecord-session-fence-acceptance-map.test.ts | 199 ++++++++
.../api/__tests__/record-fence-client.test.ts | 4 +-
.../record-session-fence-idempotency.test.ts | 5 +-
4 files changed, 653 insertions(+), 4 deletions(-)
create mode 100644 e2e/v137-record-session-fence.spec.ts
create mode 100644 src/__tests__/record-session-fence-acceptance-map.test.ts
diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts
new file mode 100644
index 000000000..24e5a969f
--- /dev/null
+++ b/e2e/v137-record-session-fence.spec.ts
@@ -0,0 +1,449 @@
+import type { Page } from "@playwright/test";
+
+import { expect, test } from "./setup/test";
+import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers";
+
+/**
+ * The record-session fence, in a real browser.
+ *
+ * `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer:
+ * a peer tab holds while the initiator's switch is in flight, because the
+ * initiator told it to. This file proves the cases that layer cannot reach —
+ * the ones where nobody tells anybody anything:
+ *
+ * * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely
+ * to "something changed";
+ * * FENCE-AC-02 the initiator's response never arrives, because the tab is
+ * torn off mid-flight;
+ * * FENCE-AC-03 the initiator is CLOSED after the server committed, so no
+ * commit broadcast is ever published;
+ * * FENCE-AC-06 a record response delayed across the switch is discarded by
+ * the client rather than painted;
+ * * FENCE-AC-07 the disk layers — CacheStorage and the service worker's
+ * `healthlog-data-*` — cannot serve one record's bytes inside another.
+ *
+ * ## Why every absence assertion here is paired
+ *
+ * "The peer never showed the owner's data" passes on a page that is
+ * permanently wedged, which is a worse outcome than the bug. So each of those
+ * legs additionally asserts the peer REACHES a usable state on the target
+ * scope once `/me` resolves. An absence-only assertion in this file would be a
+ * check that cannot fail.
+ *
+ * Uses the existing synthetic `e2e/setup` fixtures; introduces no new account
+ * anchors.
+ */
+
+const RECORD_READS = [
+ "/api/dashboard/snapshot",
+ "/api/insights/targets",
+ "/api/medications",
+ "/api/medications/compliance",
+ "/api/labs",
+] as const;
+
+const EPOCH_HEADER = "x-healthlog-record-epoch";
+const SCOPE_HEADER = "x-healthlog-record-scope";
+
+const TARGET_USERNAME = "e2e-scope-labs";
+
+async function openSwitcher(page: Page) {
+ await page.getByRole("button", { name: "User menu" }).first().click();
+ await page.locator('[data-slot="account-switcher-trigger"]').click();
+}
+
+function switcherEntry(page: Page, username = TARGET_USERNAME) {
+ return page.locator(
+ `[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
+ );
+}
+
+function deferred() {
+ let release!: () => void;
+ const promise = new Promise((resolve) => {
+ release = resolve;
+ });
+ return { promise, release };
+}
+
+/** Wait for the shell to be usable again — the paired positive control. */
+async function expectShellReady(page: Page) {
+ await expect(
+ page.locator('[data-slot="record-scope-hydration-gate"]'),
+ ).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "User menu" })).toBeVisible();
+}
+
+/** The record context a page asserted on its most recent record read. */
+function collectAssertions(
+ page: Page,
+ into: { epoch: string; scope: string }[],
+) {
+ page.on("request", (request) => {
+ const path = new URL(request.url()).pathname;
+ if (!(RECORD_READS as readonly string[]).includes(path)) return;
+ const headers = request.headers();
+ const epoch = headers[EPOCH_HEADER];
+ const scope = headers[SCOPE_HEADER];
+ if (epoch === undefined || scope === undefined) return;
+ into.push({ epoch, scope });
+ });
+}
+
+test.describe.serial("FENCE record-session fence in the browser", () => {
+ test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const initiator = await context.newPage();
+ const peer = await context.newPage();
+ const peerAssertions: { epoch: string; scope: string }[] = [];
+ collectAssertions(peer, peerAssertions);
+
+ try {
+ await Promise.all([initiator.goto("/"), peer.goto("/")]);
+ await expectShellReady(peer);
+
+ await openSwitcher(initiator);
+ const entry = switcherEntry(initiator);
+ await expect(entry).toBeVisible();
+ const accountId = await entry.getAttribute("data-account-id");
+ expect(accountId).not.toBeNull();
+
+ peerAssertions.length = 0;
+ await entry.click();
+ await expect(
+ initiator.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ // The peer reaches a usable shell again — the positive control, without
+ // which every assertion below would pass on a wedged page.
+ await peer.reload();
+ await expectShellReady(peer);
+ await expect(
+ peer.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ // And every record read it now issues names the record it is actually
+ // in, not "something". A peer that reconciled to a guess would show up
+ // here as a scope naming the wrong account or a stale epoch.
+ expect(peerAssertions.length).toBeGreaterThan(0);
+ for (const assertion of peerAssertions) {
+ expect(assertion.scope).toBe(accountId);
+ expect(assertion.epoch).toMatch(/^\d+$/);
+ expect(assertion.epoch).not.toBe("bootstrap");
+ }
+ const inRecordEpoch = Number(peerAssertions[0].epoch);
+ expect(inRecordEpoch).toBeGreaterThan(0);
+
+ // ── and the same on the way out ───────────────────────────────────
+ peerAssertions.length = 0;
+ await initiator
+ .locator('[data-slot="shared-record-banner-exit"]')
+ .click();
+ await expect(
+ initiator.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
+
+ await peer.reload();
+ await expectShellReady(peer);
+ await expect(
+ peer.locator('[data-slot="shared-record-banner"]'),
+ ).toHaveCount(0);
+
+ expect(peerAssertions.length).toBeGreaterThan(0);
+ for (const assertion of peerAssertions) {
+ expect(assertion.scope).toBe("self");
+ // Leaving moved the epoch too: a tab believing it is still inside the
+ // record is the same defect as the reverse.
+ expect(Number(assertion.epoch)).toBeGreaterThan(inRecordEpoch);
+ }
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const initiator = await context.newPage();
+ const peer = await context.newPage();
+ const peerAssertions: { epoch: string; scope: string }[] = [];
+ collectAssertions(peer, peerAssertions);
+
+ try {
+ await Promise.all([initiator.goto("/"), peer.goto("/")]);
+ await expectShellReady(peer);
+ await expectShellReady(initiator);
+
+ // Drive the switch server-side and abort the response on the way back:
+ // the write lands, the initiator never learns it did, and no commit
+ // broadcast is ever published.
+ await openSwitcher(initiator);
+ const entry = switcherEntry(initiator);
+ await expect(entry).toBeVisible();
+ const accountId = await entry.getAttribute("data-account-id");
+ expect(accountId).not.toBeNull();
+
+ await initiator.route("**/api/account/switch", async (route) => {
+ await route.fetch();
+ await route.abort();
+ });
+ await entry.click({ noWaitAfter: true });
+ await initiator.waitForTimeout(500);
+
+ // The peer must never paint owner-ready state it guessed at …
+ const guessed = peerAssertions.filter(
+ (a) => a.scope === accountId && a.epoch === "bootstrap",
+ );
+ expect(guessed).toEqual([]);
+
+ // … and must still REACH ready on the target scope once `/me` answers.
+ // Without this the leg above would pass on a permanently wedged page.
+ await peer.reload();
+ await expectShellReady(peer);
+ await expect(
+ peer.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+ expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true);
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const initiator = await context.newPage();
+ const peer = await context.newPage();
+
+ try {
+ await Promise.all([initiator.goto("/"), peer.goto("/")]);
+ await expectShellReady(peer);
+
+ await openSwitcher(initiator);
+ const entry = switcherEntry(initiator);
+ await expect(entry).toBeVisible();
+
+ // Hold the response, close the tab while it is in flight. The server has
+ // committed; nothing will ever broadcast the commit.
+ const held = deferred();
+ await initiator.route("**/api/account/switch", async (route) => {
+ await route.fetch();
+ await held.promise;
+ await route.abort();
+ });
+ await entry.click({ noWaitAfter: true });
+ await initiator.waitForTimeout(300);
+ held.release();
+ await initiator.close();
+
+ // The surviving tab is not permanently gated: `/me` is the bootstrap and
+ // it answers regardless of who began the transition.
+ await peer.reload();
+ await expectShellReady(peer);
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const peer = await context.newPage();
+
+ try {
+ await peer.goto("/");
+ await expectShellReady(peer);
+
+ // A switch driven from a request context that runs no client journal at
+ // all — the "raw or external" case the cooperative layer cannot see.
+ await openSwitcher(peer);
+ const entry = switcherEntry(peer);
+ await expect(entry).toBeVisible();
+ const accountId = await entry.getAttribute("data-account-id");
+ await peer.keyboard.press("Escape");
+
+ const response = await context.request.post("/api/account/switch", {
+ data: { accountId },
+ });
+ expect(response.status()).toBe(200);
+
+ // The tab is now asserting a context the row has left. Its next record
+ // read is refused, and the browser reconciles rather than leaving.
+ const refused = peer.waitForResponse(
+ (r) =>
+ (RECORD_READS as readonly string[]).includes(
+ new URL(r.url()).pathname,
+ ) && r.status() === 409,
+ );
+ await peer.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await refused;
+
+ // Reconciled, not evicted: the tab ends up INSIDE the record the
+ // external switch put it in.
+ await expect(
+ peer.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+ await expectShellReady(peer);
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const page = await context.newPage();
+
+ try {
+ await page.goto("/");
+ await expectShellReady(page);
+
+ // Hold one record read open across the switch. It was admitted under the
+ // old context and the server echoes that context honestly; the client
+ // must drop it rather than paint it.
+ const held = deferred();
+ let heldEcho: string | null = null;
+ await page.route("**/api/medications", async (route) => {
+ const response = await route.fetch();
+ heldEcho = response.headers()[EPOCH_HEADER] ?? null;
+ await held.promise;
+ await route.fulfill({ response });
+ });
+
+ await openSwitcher(page);
+ const entry = switcherEntry(page);
+ await expect(entry).toBeVisible();
+ await entry.click();
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ held.release();
+ // The stale response resolved. It carried an echo from before the
+ // switch, so it is discarded — the page stays on the record it is
+ // actually in and does not paint the previous one's rows.
+ await page.waitForTimeout(300);
+ expect(heldEcho).not.toBeNull();
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+ await expectShellReady(page);
+ } finally {
+ await context.close();
+ }
+ });
+
+ test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({
+ browser,
+ }) => {
+ const context = await browser.newContext({
+ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH,
+ });
+ const page = await context.newPage();
+
+ try {
+ await page.goto("/");
+ // The gate is spelled out here rather than left inside `expectShellReady`
+ // because this test's next act is a one-shot `page.evaluate`, and
+ // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source
+ // text between the navigation and the read. A gate hidden in a helper is
+ // invisible to that guard — and to the next person wondering what this
+ // read is waiting for.
+ await expect(
+ page.getByRole("button", { name: "User menu" }),
+ ).toBeVisible();
+ await expectShellReady(page);
+ // Warm the cached record reads the service worker is configured to hold.
+ for (const path of [
+ "/api/dashboard/snapshot",
+ "/api/measurements",
+ "/api/medications",
+ ]) {
+ await page.evaluate(async (p) => {
+ await fetch(p).catch(() => {});
+ }, path);
+ }
+ await page.waitForTimeout(500);
+
+ // POSITIVE CONTROL, first: the owner snapshot really WAS on disk before
+ // the switch. A test that seeded nothing proves nothing.
+ const seeded = await page.evaluate(async () => {
+ const names = await caches.keys();
+ const data = names.filter((n) => n.startsWith("healthlog-data-"));
+ let entries = 0;
+ for (const name of data) {
+ entries += (await (await caches.open(name)).keys()).length;
+ }
+ return { names, dataCaches: data, entries };
+ });
+ expect(seeded.dataCaches.length).toBeGreaterThan(0);
+ expect(seeded.entries).toBeGreaterThan(0);
+
+ // The eviction claim: after activation only the current-version caches
+ // survive. A pre-fence cached response cannot be served, because its
+ // cache no longer exists.
+ const stale = seeded.names.filter(
+ (n) =>
+ /^healthlog-(static|pages|data)-/.test(n) &&
+ !seeded.dataCaches.includes(n) &&
+ !n.includes(seeded.dataCaches[0].replace("healthlog-data-", "")),
+ );
+ expect(stale).toEqual([]);
+
+ await openSwitcher(page);
+ const entry = switcherEntry(page);
+ await expect(entry).toBeVisible();
+ const accountId = await entry.getAttribute("data-account-id");
+ await entry.click();
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ // The switch wipes the disk layers before it reloads, so nothing from
+ // the previous record survives into the new one.
+ const afterSwitch = await page.evaluate(async () => {
+ const names = (await caches.keys()).filter((n) =>
+ n.startsWith("healthlog-data-"),
+ );
+ let entries = 0;
+ for (const name of names) {
+ entries += (await (await caches.open(name)).keys()).length;
+ }
+ return entries;
+ });
+ expect(afterSwitch).toBe(0);
+
+ // Offline, inside the target record: no owner bytes are painted …
+ await context.setOffline(true);
+ await page.reload().catch(() => {});
+ await expect(
+ page.locator('[data-slot="shared-record-banner"]'),
+ ).toBeVisible();
+
+ // … and the paired positive control — the target's own offline data IS
+ // reachable once it has been fetched inside this record.
+ await context.setOffline(false);
+ await page.reload();
+ await expectShellReady(page);
+ expect(accountId).not.toBeNull();
+ } finally {
+ await context.close();
+ }
+ });
+});
diff --git a/src/__tests__/record-session-fence-acceptance-map.test.ts b/src/__tests__/record-session-fence-acceptance-map.test.ts
new file mode 100644
index 000000000..9d8261d6b
--- /dev/null
+++ b/src/__tests__/record-session-fence-acceptance-map.test.ts
@@ -0,0 +1,199 @@
+/**
+ * The ten acceptance cases of the record-session fence, mapped to the files
+ * that prove them — as a runnable artifact rather than a table in a document.
+ *
+ * A dropped case is the failure this exists to catch. A test deleted during a
+ * refactor takes its coverage with it silently; a document claiming ten cases
+ * keeps claiming ten. So every case carries a stable id, the id appears in the
+ * TITLE of at least one `it(` / `test(` in the file named below, and this file
+ * asserts the mapping.
+ *
+ * ## What this guard cannot do, said plainly
+ *
+ * It proves an id appears in a test title. It does NOT prove that test asserts
+ * anything worth asserting — that is what the named instruments are for, and
+ * each case below records which instrument carries its weight. A guard that
+ * only counted titles would be satisfiable by ten empty tests, so the
+ * per-case `instrument` field is a review anchor, not a claim this file
+ * verifies.
+ *
+ * ## Executed here versus deferred
+ *
+ * Five cases are proved by Vitest files that run in the ordinary gate. Five
+ * ride `e2e/v137-record-session-fence.spec.ts` and therefore run only in the
+ * Playwright acceptance stage. That split is recorded in `runner` below and
+ * asserted, so nobody can read a green run of THIS file as "all ten proved" —
+ * it means "all ten are claimed, and here is which ones have actually
+ * executed".
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const ROOT = process.cwd();
+
+interface AcceptanceCase {
+ id: string;
+ /** What the handoff asked for. */
+ case: string;
+ /** Where it lives, relative to the repository root. */
+ home: string;
+ /** Vitest runs in the ordinary gate; Playwright runs in the acceptance stage. */
+ runner: "vitest" | "playwright";
+ /** The thing that makes the assertion falsifiable. A review anchor. */
+ instrument: string;
+}
+
+const CASES: AcceptanceCase[] = [
+ {
+ id: "FENCE-AC-01",
+ case: "Switch in and out; peers reconcile to the exact new epoch and scope",
+ home: "e2e/v137-record-session-fence.spec.ts",
+ runner: "playwright",
+ instrument:
+ "Every record read the peer issues afterwards is inspected for the asserted epoch and scope; the epoch must have moved and the scope must name the record, not merely differ. Paired with a shell-ready assertion so a wedged page cannot pass.",
+ },
+ {
+ id: "FENCE-AC-02",
+ case: "Server switch driven externally, initiator response aborted",
+ home: "e2e/v137-record-session-fence.spec.ts",
+ runner: "playwright",
+ instrument:
+ "Peers never assert a guessed owner scope under the bootstrap sentinel, AND do reach ready on the target scope once /me resolves. The second half is mandatory: an absence-only assertion passes on a permanently wedged page.",
+ },
+ {
+ id: "FENCE-AC-03",
+ case: "Initiator closed after the server commit, before any commit broadcast",
+ home: "e2e/v137-record-session-fence.spec.ts",
+ runner: "playwright",
+ instrument:
+ "The surviving tab reaches ready rather than sitting behind a permanent hydration gate — /me is the bootstrap and answers regardless of who began the transition.",
+ },
+ {
+ id: "FENCE-AC-04",
+ case: "Raw or external switch; a stale peer read and mutation refused before handler or database work",
+ home: "tests/integration/record-session-fence.test.ts",
+ runner: "vitest",
+ instrument:
+ "AccountGrant.lastUsedAt unchanged and no audit_logs row with action = 'sharing.record.accessed' for the pair — both are written only past admission, so their absence is the no-database-work proof. Asserted BEFORE the status code so a removed fence fails on the instrument rather than being masked. Paired with a positive control that moves both. Also the findFirst zero-call spy in src/lib/__tests__/acting-account-resolver.test.ts, plus an e2e leg driving the switch from a request context that runs no client journal.",
+ },
+ {
+ id: "FENCE-AC-05",
+ case: "Owner idempotent mutation and replay, switch before handler authorization",
+ home: "tests/integration/record-session-fence-idempotency.test.ts",
+ runner: "vitest",
+ instrument:
+ "The seeded owner cell is byte-unchanged — same id, responseStatus, responseBody, expiresAt, createdAt. expiresAt carries the weight: completion rewrites it to now+24h and a fresh claim to now+2min, so any wrapper that reached the cache moves it. The 'no row exists' assertion was rejected as unfalsifiable, because releaseClaim deletes the pending row whenever the handler throws.",
+ },
+ {
+ id: "FENCE-AC-06",
+ case: "Record response delayed across the switch; the client discards and holds",
+ home: "src/lib/api/__tests__/record-fence-client.test.ts",
+ runner: "vitest",
+ instrument:
+ "A response whose echoed epoch is one behind the adopted one makes the caller throw and fires the hold exactly once, while a response carrying NO echo is returned untouched and fires nothing. The second half is the version-poller case and is what stops the rule from being discard-on-absence. An e2e leg holds a real record read open across a real switch.",
+ },
+ {
+ id: "FENCE-AC-07",
+ case: "IndexedDB and CacheStorage seeded with an owner snapshot, switch in, go offline",
+ home: "e2e/v137-record-session-fence.spec.ts",
+ runner: "playwright",
+ instrument:
+ "Positive control first — the owner snapshot really was in CacheStorage before the switch, asserted by a non-zero entry count. Then the eviction claim of the cache-version policy, then a zero entry count after the switch, then offline with the target's own data reachable.",
+ },
+ {
+ id: "FENCE-AC-08",
+ case: "Concurrent switchers; monotonic compare-and-set ordering, a stale switch cannot override",
+ home: "tests/integration/record-session-fence.test.ts",
+ runner: "vitest",
+ instrument:
+ "Two same-epoch switches to genuinely distinct targets resolve to exactly one 'applied' and one 'stale', and the epoch handed back to the winner equals the row's committed value. A separate leg pins the no-op case, where the trigger declines to move and 'applied' is the honest answer.",
+ },
+ {
+ id: "FENCE-AC-09",
+ case: "Revoke, account deletion, managed-profile lifecycle, FK-driven clearing",
+ home: "tests/integration/record-session-epoch-trigger.test.ts",
+ runner: "vitest",
+ instrument:
+ "The ON DELETE SET NULL referential action and a raw SQL clear both bump the epoch with no application code running, while the per-request last_active_at and expires_at writes do not. A lifecycle leg in tests/integration/record-session-fence.test.ts then asserts the fence refuses the pre-transition context after a revoke and after an owner deletion.",
+ },
+ {
+ id: "FENCE-AC-10",
+ case: "Pre-fence cookie callers fail closed into their reload; token and native keep the frozen contract",
+ home: "src/__tests__/record-session-fence-compat.test.ts",
+ runner: "vitest",
+ instrument:
+ "Every link in the pre-fence tab's only recovery chain is frozen, and the 409 is asserted ABSENT from the grant-loss predicate. The fence's own positive controls live in tests/integration/record-session-fence.test.ts: a Bearer request with no fence header is served, and an epoch-zero cookie request with no fence header is served. Without those two a fence that refused everything would pass every negative test in this plan.",
+ },
+];
+
+function read(rel: string): string {
+ return readFileSync(join(ROOT, rel), "utf8");
+}
+
+/** Every `it(` / `test(` title in a file. */
+function testTitles(source: string): string[] {
+ const titles: string[] = [];
+ for (const m of source.matchAll(
+ /\b(?:it|test)(?:\.\w+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g,
+ )) {
+ titles.push(m[2]);
+ }
+ return titles;
+}
+
+describe("the ten acceptance cases are all claimed by a runnable test", () => {
+ it("declares exactly ten distinct ids", () => {
+ expect(CASES).toHaveLength(10);
+ expect(new Set(CASES.map((c) => c.id)).size).toBe(10);
+ for (const c of CASES) {
+ expect(c.id).toMatch(/^FENCE-AC-\d{2}$/);
+ // A review anchor with nothing in it is not one.
+ expect(c.instrument.length).toBeGreaterThan(80);
+ expect(c.case.length).toBeGreaterThan(20);
+ }
+ });
+
+ it.each(CASES)("$id appears in a test title in $home", (c) => {
+ const titles = testTitles(read(c.home));
+ // Non-zero proof: a matcher that found no titles at all would make the
+ // membership check below vacuous.
+ expect(titles.length).toBeGreaterThan(0);
+ const carrying = titles.filter((t) => t.includes(c.id));
+ expect(carrying.length).toBeGreaterThan(0);
+ });
+
+ it("records which cases have actually executed in this gate", () => {
+ // Five cases ride Playwright and therefore prove nothing until the
+ // acceptance stage runs. Stating the split here is what stops a green run
+ // of this file from being read as "all ten proved".
+ const vitest = CASES.filter((c) => c.runner === "vitest").map((c) => c.id);
+ const playwright = CASES.filter((c) => c.runner === "playwright").map(
+ (c) => c.id,
+ );
+
+ expect(vitest).toEqual([
+ "FENCE-AC-04",
+ "FENCE-AC-05",
+ "FENCE-AC-06",
+ "FENCE-AC-08",
+ "FENCE-AC-09",
+ "FENCE-AC-10",
+ ]);
+ expect(playwright).toEqual([
+ "FENCE-AC-01",
+ "FENCE-AC-02",
+ "FENCE-AC-03",
+ "FENCE-AC-07",
+ ]);
+ expect(vitest.length + playwright.length).toBe(10);
+ });
+
+ it("names only files that exist and carry real tests", () => {
+ for (const home of new Set(CASES.map((c) => c.home))) {
+ const source = read(home);
+ expect(source.length).toBeGreaterThan(500);
+ expect(testTitles(source).length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/src/lib/api/__tests__/record-fence-client.test.ts b/src/lib/api/__tests__/record-fence-client.test.ts
index 831af33dd..41eb251f8 100644
--- a/src/lib/api/__tests__/record-fence-client.test.ts
+++ b/src/lib/api/__tests__/record-fence-client.test.ts
@@ -115,7 +115,7 @@ describe("what the browser asserts on a request", () => {
});
describe("what the browser does with an echoed context", () => {
- it("serves a response carrying no echo, and enters no hold", () => {
+ it("FENCE-AC-06 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 });
@@ -137,7 +137,7 @@ describe("what the browser does with an echoed context", () => {
expect(hold).not.toHaveBeenCalled();
});
- it("discards a response delayed across a switch, and enters the hold", () => {
+ it("FENCE-AC-06 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.
diff --git a/tests/integration/record-session-fence-idempotency.test.ts b/tests/integration/record-session-fence-idempotency.test.ts
index e241671ae..c0691cb9a 100644
--- a/tests/integration/record-session-fence-idempotency.test.ts
+++ b/tests/integration/record-session-fence-idempotency.test.ts
@@ -199,8 +199,9 @@ describe("FENCE-AC-05 an idempotent write under a moved record context", () => {
expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE);
// (b) the seeded cell is untouched — nothing read it, claimed it, or
- // rewrote it. `updatedAt` is the falsifiable half: a wrapper that reached
- // the cache and released a claim would leave the row present but moved.
+ // rewrote it. `expiresAt` is the falsifiable half: completion rewrites it
+ // to now+24h and a fresh claim to now+2min, so a wrapper that reached the
+ // cache at all would move it.
const after = await cells();
expect(after).toHaveLength(1);
expect(after[0].id).toBe(before.id);
From ffe38c9bb695961529095444524adead2fcf5374 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 6 Aug 2026 20:35:17 +0200
Subject: [PATCH 43/54] fix(02-12.1): reach fenced document routes through the
app transport
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A browser-issued ,