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; +}