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
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 15:06:12 +02:00
1 parent c66b29d22a
commit 38214e4726
13 files changed
+711 -45

No files matched your search

+116
View File
@@ -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<void>((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<typeof deferred>[] = [];
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();
}
});
});
@@ -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("<AuthShell> — 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 {
+14 -3
View File
@@ -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 <RecordScopeHydrationGate label={t("nav.loadingScreen")} />;
}
+67 -10
View File
@@ -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({
<ThemeProvider>
<I18nProvider initialLocale={initialLocale}>
<QueryPersistenceBridge />
<RecordSessionTransitionBridge />
<QueryVisibilityRefreshBridge />
<DashboardSnapshotPreloader />
<OfflineMutationToaster />
+49 -24
View File
@@ -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<SwitchResponse> {
}
/**
* 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<never> {
setRecordScope(accountId);
async function landOnRecord(destination = "/"): Promise<never> {
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]);
}
+13
View File
@@ -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<AuthUser> {
} 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,
+9 -2
View File
@@ -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",
);
}
@@ -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,
);
}
+2 -1
View File
@@ -172,6 +172,7 @@ function currentUserId(queryClient: QueryClient): string | null {
export async function restorePersistedQueryCache(
queryClient: QueryClient,
buildVersion: string,
canHydrate: () => boolean = () => true,
): Promise<void> {
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
@@ -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.
*
@@ -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,
});
});
});
+5 -5
View File
@@ -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
@@ -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<RecordSessionPhase, "ready"> | "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<WireTransition>;
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<unknown>) => {
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;
}