Merge commit '38214e472' into work/v137-record-session-fence

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:10:56 +02:00
commit a08e82d0be
81 files changed
+3505 -386

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import type {
AccountAccessEntry,
AccountRecordKind,
} from "@/lib/sharing/account-access-view";
export type RecordAccessPresentation = "view" | "view-and-add" | "manage";
export interface RecordPresentation {
access: RecordAccessPresentation;
recordKind: AccountRecordKind;
}
/**
* The server publishes both a resolved `canWrite` decision and the canonical
* three-level label. Chrome binds those values here without treating either as
* an authorization input: a handler still resolves the active grant afresh.
*/
export function resolveRecordPresentation(
entry: Pick<AccountAccessEntry, "canWrite" | "level" | "recordKind">,
): 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"
? "manage"
: entry.canWrite
? "view-and-add"
: "view",
recordKind: entry.recordKind,
};
}
+108
View File
@@ -0,0 +1,108 @@
import {
ENTIRE_RECORD,
SHARE_DOMAINS,
type ShareDomain,
type ShareScope,
} from "@/lib/sharing/scope";
/**
* The browser route families a scoped grant can present.
*
* This is deliberately a presentation inventory, not an authorization table.
* `sections` arrives here as the server-resolved value from `accountAccess`;
* the request handler still evaluates the active grant for every read and
* mutation. Keeping the browser's route families closed lets a new domain
* fail closed in chrome until it has been intentionally classified.
*/
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements"],
medications: ["/medications"],
labs: ["/labs"],
profile: ["/profile"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
documents: ["/documents"],
} as const satisfies Record<ShareDomain, readonly `/${string}`[]>;
/**
* Routes whose reads span multiple domains. A selected-domain grant never
* opens these, even when it happens to name every currently known domain.
*/
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
interface RouteFamily {
href: string;
scope: ShareScope;
}
const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [
...WHOLE_RECORD_ROUTE_FAMILIES.map((href): RouteFamily => ({
href,
scope: ENTIRE_RECORD,
})),
...SHARE_DOMAINS.flatMap((domain): RouteFamily[] =>
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href): RouteFamily => ({
href,
scope: domain,
})),
),
];
function matchesRouteFamily(pathname: string, href: string): boolean {
return href === "/"
? pathname === href
: pathname === href || pathname.startsWith(`${href}/`);
}
function includesScope(
sections: readonly ShareDomain[] | null,
scope: ShareScope,
): boolean {
if (sections === null) return true;
return scope !== ENTIRE_RECORD && sections.includes(scope);
}
/**
* Server-resolved navigation paint for the active shared record.
*
* The return value never makes an authorization decision. It only prevents a
* known-unavailable page from mounting its reads after the server has already
* said which record sections the browser is inside.
*/
export function resolveSharedRecordNavigation(
sections: readonly ShareDomain[] | null,
) {
const routeFamilies = SHARED_RECORD_ROUTE_FAMILIES.filter((family) =>
includesScope(sections, family.scope),
);
return {
destinationHrefs: routeFamilies.map((family) => family.href),
allowsPath(pathname: string): boolean {
return routeFamilies.some((family) =>
matchesRouteFamily(pathname, family.href),
);
},
};
}
/** 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?
*
* This helper is intentionally named for presentation. It must never be used
* by a route handler or to choose an API request's target record.
*/
export function isSharedRecordPathPresentable(
pathname: string,
sections: readonly ShareDomain[] | null,
): boolean {
return resolveSharedRecordNavigation(sections).allowsPath(pathname);
}
+6 -66
View File
@@ -23,6 +23,7 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { accountAccessBlockSchema } from "@/lib/sharing/account-access-schema";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import {
inviteGrantSchema,
@@ -210,72 +211,11 @@ export const accountSelectorParameter: NonNullable<
// ── What the client is told it may do ────────────────────────────────────────
const accountAccessEntry = z
.object({
accountId: z
.string()
.describe(
"The account whose record it is — the value POST /api/account/switch takes, and the value the X-HealthLog-Account header carries.",
),
username: z.string(),
displayName: z.string().nullable(),
access: z
.enum(["read", "write"])
.describe(
"The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.",
),
level: z
.enum(["read", "write", "manage"])
.describe(
"The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.",
),
recordKind: z
.enum(["self", "shared", "managed"])
.describe(
"Server-resolved presentation metadata for the record. It is never accepted as an authorization input.",
),
sections: z
.array(shareSection)
.nullable()
.describe(
"The sections of that record this caller may open, or null for the entire record. Null is first-class and is what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal, which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the consent screen's reading order.",
),
canWrite: z
.boolean()
.describe(
"Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE additionally admits is said by `level`. Resolved server-side. Render it; never derive it.",
),
})
.meta({
id: "AccountAccessEntry",
description:
"One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL here would resolve to a refusal. Clients paint their initials fallback.",
});
export const accountAccessBlock = z
.object({
accounts: z
.array(accountAccessEntry)
.describe("Every record this caller may open, newest grant first."),
active: accountAccessEntry
.nullable()
.describe(
"The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.",
),
recordKind: z
.enum(["self", "shared", "managed"])
.describe(
"The server-resolved kind of the record currently in view. `self` applies when `active` is null.",
),
canSwitch: z
.boolean()
.describe("Whether there is anywhere to switch to. Bind it directly."),
})
.meta({
id: "AccountAccess",
description:
"Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.",
});
export const accountAccessBlock = accountAccessBlockSchema.meta({
id: "AccountAccess",
description:
"Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.",
});
/**
* The account payload, documented for the one field this release adds.
+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
@@ -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<Response>((_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,
});
});
});
@@ -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(
<>
@@ -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<string, unknown> {
+6 -3
View File
@@ -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,
});
}
+15 -2
View File
@@ -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<AchievementsPayload> {
return apiGet<AchievementsPayload>("/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<AccountAccessEntry, "sections"> | 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<AchievementsPayload> {
export function useAchievementsQuery(
options: UseAchievementsQueryOptions = {},
): UseQueryResult<AchievementsPayload, Error> {
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({
+27 -7
View File
@@ -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<DashboardSnapshot> {
async function fetchDashboardSnapshot(
signal?: AbortSignal,
): Promise<DashboardSnapshot> {
// 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<DashboardSnapshot>("/api/dashboard/snapshot");
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot", { signal });
}
/** Handoff freshness window — mirrors the hook's `staleTime`. */
@@ -68,12 +71,18 @@ const PRELOAD_MAX_AGE_MS = 60_000;
let preloadedSnapshot: {
promise: Promise<DashboardSnapshot>;
startedAt: number;
recordScope: string | null;
} | null = null;
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.
*
@@ -88,7 +97,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 +140,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<DashboardSnapshot> | 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) {
@@ -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");
@@ -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,
});
});
});
+2
View File
@@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { workoutKeys } from "./workouts";
@@ -65,6 +66,7 @@ export const queryKeys = {
...customMetrics,
...nutrientKeys,
...recordSettingsKeys,
...profileKeys,
...sharingKeys,
};
+4
View File
@@ -0,0 +1,4 @@
/** Query keys for the bounded, read-only profile summary surface. */
export const profileKeys = {
profileSummary: () => ["profile", "summary"] as const,
};
+54 -4
View File
@@ -51,7 +51,8 @@
*/
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__";
/**
* The in-process copy. Seeded from storage on first read so the hash function
@@ -59,10 +60,15 @@ const STORAGE_KEY = "healthlog-record-scope";
*/
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 {
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
@@ -104,13 +110,53 @@ 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 */
}
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 !== RECORD_SCOPE_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);
};
}
/**
* 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. */
@@ -138,6 +184,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}`;
}
@@ -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;
}
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/db";
import { readHealthProfileFacts } from "@/lib/profile/health-facts";
export interface ProfileSummaryPayload {
allergies: Array<{ substance: string; category: string }>;
familyHistory: Array<{ condition: string; relationship: string }>;
facts: Array<{ label: string; value: string }>;
}
/**
* The shared profile surface deliberately carries only the bounded, structured
* facts a record viewer needs. It does not load settings, AI configuration, or
* any encrypted free-text field.
*/
export async function readProfileSummary(
userId: string,
): Promise<ProfileSummaryPayload> {
const [allergies, familyHistory, healthFacts] = await Promise.all([
prisma.allergy.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
take: 25,
select: { substance: true, category: true },
}),
prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
take: 25,
select: { condition: true, relationship: true },
}),
readHealthProfileFacts(userId),
]);
return {
allergies,
familyHistory,
facts: Object.values(healthFacts.current).flatMap((fact) =>
fact?.value && !fact.unreadable
? [{ label: fact.kind, value: fact.value }]
: [],
),
};
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { parseAccountAccess } from "../account-access-schema";
const sharedEntry = {
accountId: "record-a",
username: "record-a",
displayName: "Record A",
access: "read",
level: "read",
recordKind: "shared",
sections: ["labs"],
canWrite: false,
};
function accessBlock(overrides: Record<string, unknown> = {}) {
return {
accounts: [sharedEntry],
active: sharedEntry,
recordKind: "shared",
canSwitch: true,
...overrides,
};
}
describe("parseAccountAccess", () => {
it("preserves canonical shared payloads, including an empty scope", () => {
const scoped = parseAccountAccess(accessBlock());
const empty = parseAccountAccess(
accessBlock({
accounts: [{ ...sharedEntry, sections: [] }],
active: { ...sharedEntry, sections: [] },
}),
);
expect(scoped?.active?.sections).toEqual(["labs"]);
expect(empty?.active?.sections).toEqual([]);
});
it("preserves the canonical manage compatibility fields", () => {
const manager = {
...sharedEntry,
access: "write",
level: "manage",
sections: null,
canWrite: true,
};
const parsed = parseAccountAccess(
accessBlock({ accounts: [manager], active: manager }),
);
expect(parsed?.active).toMatchObject({
level: "manage",
access: "write",
sections: null,
canWrite: true,
});
});
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" }],
["unknown section", { sections: ["other"] }],
["duplicated section", { sections: ["labs", "labs"] }],
["non-array section", { sections: "labs" }],
["read marked writable", { canWrite: true }],
["write marked read-only", { access: "write", level: "write" }],
[
"scoped manage",
{ access: "write", level: "manage", sections: ["labs"], canWrite: true },
],
[
"manage with read legacy field",
{ access: "read", level: "manage", sections: null, canWrite: true },
],
])("fails closed for %s entries", (_name, entryOverrides) => {
const entry = { ...sharedEntry, ...entryOverrides };
expect(
parseAccountAccess(accessBlock({ accounts: [entry], active: entry })),
).toBeNull();
});
it("fails closed for a stale active entry or a contradictory switch block", () => {
expect(
parseAccountAccess(
accessBlock({ active: { ...sharedEntry, accountId: "stale-record" } }),
),
).toBeNull();
expect(parseAccountAccess(accessBlock({ canSwitch: false }))).toBeNull();
expect(
parseAccountAccess({
accounts: [],
active: null,
recordKind: "self",
canSwitch: true,
}),
).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { z } from "zod/v4";
import type { AccountAccess } from "./account-access-view";
import { SHARE_DOMAINS } from "./scope";
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
const accountRecordKindSchema = z.enum(["shared", "managed"]);
const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable();
/** The browser's resolved account-access payload, shared with OpenAPI. */
export const accountAccessEntrySchema = z
.object({
accountId: z.string().min(1),
username: z.string().min(1),
displayName: z.string().nullable(),
access: z.enum(["read", "write"]),
level: accountAccessLevelSchema,
recordKind: accountRecordKindSchema,
sections: shareSectionsSchema,
canWrite: z.boolean(),
})
.superRefine((entry, ctx) => {
const uniqueSections = new Set(entry.sections ?? []);
if (uniqueSections.size !== (entry.sections?.length ?? 0)) {
ctx.addIssue({
code: "custom",
message: "Account access sections must not repeat a domain",
path: ["sections"],
});
}
const isManage = entry.level === "manage";
if (isManage && (entry.access !== "write" || entry.sections !== null)) {
ctx.addIssue({
code: "custom",
message: "Manage access must use legacy write access and whole scope",
});
}
if (entry.canWrite !== (entry.level !== "read")) {
ctx.addIssue({
code: "custom",
message: "Account access write flag disagrees with its level",
path: ["canWrite"],
});
}
if (!isManage && entry.access !== entry.level) {
ctx.addIssue({
code: "custom",
message: "Legacy access must agree with the resolved level",
path: ["access"],
});
}
});
export const accountAccessBlockSchema = z.object({
accounts: z.array(accountAccessEntrySchema),
active: accountAccessEntrySchema.nullable(),
recordKind: z.enum(["self", "shared", "managed"]),
canSwitch: z.boolean(),
});
/** Both published views of the active record must name the same resolved grant. */
function matchesCanonicalAccountAccessEntry(
active: z.infer<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): 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);
if (!parsed.success) return null;
const { accounts, active, recordKind, canSwitch } = parsed.data;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (
!canonicalActive ||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
) {
return null;
}
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+2 -1
View File
@@ -28,6 +28,7 @@ export type LegacyAccountAccessLevel = "read" | "write";
/** The server-resolved kind of record a presentation is naming. */
export type AccountRecordKind = "self" | "shared" | "managed";
export type SharedAccountRecordKind = Exclude<AccountRecordKind, "self">;
/**
* Read the portion of an account-access entry that an older whole-record
@@ -68,7 +69,7 @@ export interface AccountAccessEntry {
* What kind of record this is. This is presentation metadata only; it is
* resolved by the server and never accepted as an authorization input.
*/
recordKind: AccountRecordKind;
recordKind: SharedAccountRecordKind;
/**
* v1.37.0 — which sections of the record this grant opens, or `null` for
* the entire record.