- {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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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;
+}