mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge commit '38214e472' into work/v137-record-session-fence
This commit is contained in:
commit
a08e82d0be
81 files changed
+3505
-386
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Query keys for the bounded, read-only profile summary surface. */
|
||||
export const profileKeys = {
|
||||
profileSummary: () => ["profile", "summary"] as const,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user