Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
60 files changed
+5648 -174

No files matched your search

+175
View File
@@ -121,13 +121,188 @@ const switchResponse = z
"The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login.",
});
const recordActivityEntry = z
.object({
id: z.string(),
actor: grantParty
.nullable()
.describe(
"Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as though the owner had done it themselves.",
),
action: z.string(),
accesses: z
.number()
.int()
.nullable()
.describe(
"How many times that delegate opened the record on that day, for a coalesced read row. Null for anything else — a single act has no count, and a 1 there would invite adding up numbers that mean different things.",
),
createdAt: z.string(),
})
.meta({ id: "RecordActivityEntry" });
const recordActivityResponse = z
.object({
entries: z.array(recordActivityEntry),
retentionDays: z
.number()
.int()
.describe(
"How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it beside the rows.",
),
})
.meta({
id: "RecordActivity",
description:
"The owner's record of what somebody else did in their record, bounded by the instance's audit retention.",
});
const sharingRefusal = {
description:
"Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted nothing — the refusal is not an enumeration oracle.",
content: { "application/json": { schema: errorEnvelope } },
};
/**
* The per-request account selector, published once.
*
* No route entry references it yet, and that is the honest state of the
* contract: the routes that read a record other than the caller's own arrive
* with the delegable-surface migration, and each one will `$ref` this
* parameter as it lands. Publishing the selector now is what lets a native
* client be built against a tagged spec rather than against a description of
* one — the header is live in the resolver today, and a request that sends it
* to a route which has not declared the mode is refused rather than quietly
* served the caller's own data.
*
* Defined here rather than in the components block so the header's name stays
* in two files, which `src/__tests__/acting-account-boundary-guard.test.ts`
* holds it to: the resolver that reads it, and this one that publishes it.
*/
export const accountSelectorParameter: NonNullable<
NonNullable<ZodOpenApiObject["components"]>["parameters"]
>[string] = {
name: "X-HealthLog-Account",
in: "header",
required: false,
schema: { type: "string", maxLength: 64 },
description:
"Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.",
};
// ── 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"]),
canWrite: z
.boolean()
.describe(
"Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. 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.",
),
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.",
});
/**
* The account payload, documented for the one field this release adds.
*
* Loose on purpose. `GET /api/auth/me` has never been in this spec, and its
* remaining fields are the account's own preference envelope — thirty-odd keys
* that no contract has ever enumerated. Specifying `accountAccess` and
* declaring the rest open is honest; enumerating a subset and closing the
* object would publish a contract the server does not keep.
*/
const accountPayload = z
.looseObject({
id: z.string(),
username: z.string(),
accountAccess: accountAccessBlock,
})
.meta({
id: "AccountPayload",
description:
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this payload still describes the CALLER — their preferences, their modules, their identity — because display preferences belong to the person at the keyboard rather than to the record they are reading.",
});
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/me": {
get: {
tags: ["Auth"],
summary: "The signed-in account, and what sharing lets it do",
description:
"Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.",
responses: {
...stdResponses,
"200": {
description: "The account payload.",
content: {
"application/json": {
schema: dataEnvelope(accountPayload, "AccountPayloadEnvelope"),
},
},
},
"403": {
description:
"A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/account/activity": {
get: {
tags: ["Account sharing"],
summary: "Who opened this record, and when",
description:
"Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.",
responses: {
...stdResponses,
"200": {
description: "The activity, and how far back it can see.",
content: {
"application/json": {
schema: dataEnvelope(
recordActivityResponse,
"RecordActivityEnvelope",
),
},
},
},
"403": sharingRefusal,
},
},
},
"/api/account/grants": {
get: {
tags: ["Account sharing"],
+9 -1
View File
@@ -21,7 +21,10 @@
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { accountSharingPaths } from "./account-sharing";
import {
accountSelectorParameter,
accountSharingPaths,
} from "./account-sharing";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { allergyPaths } from "./allergies";
import { authPaths } from "./auth";
@@ -149,6 +152,11 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
medicationDoseHistoryImportFatalReasonEnum,
CoachPrefs: coachPrefsSchema,
},
// v1.36.0 — the per-request account selector, defined in the sharing route
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
// holding the header's name to two files: the resolver that reads it, and
// the one place that publishes it.
parameters: { AccountSelector: accountSelectorParameter },
securitySchemes: {
bearerAuth: {
type: "http",
@@ -0,0 +1,213 @@
/**
* Nothing of somebody else's record reaches this device's disk, and nothing on
* it is painted while somebody else's record is open.
*
* The full page reload a switch performs clears the in-memory cache. These two
* layers are the ones it cannot reach: the IndexedDB snapshot, and (through
* the same wipe helper) the service worker's read-data cache. Between them
* they are the reason a switched browser can paint the previous record's
* numbers with nothing in the page's own code being wrong — the answer would
* not have come from the page.
*
* The IndexedDB here is a small fake rather than a mock of the persister's own
* calls. A test that asserted "idbSet was not called" would prove the code
* took a branch; this one proves the store is empty, which is the fact
* somebody's privacy depends on.
*
* Mutation checks, both run:
* - delete the `isReadingSharedRecord()` guard in `flush` → "writes nothing
* to disk while inside another record" goes red, with the snapshot found
* in the store.
* - delete the guard in `restorePersistedQueryCache` → "restores nothing
* while inside another record" goes red on the cache-size line, with one
* entry hydrated. The line above it stays GREEN under that mutation, which
* is the point of having both: the record-scoped hash already makes the
* restored entry unreadable, so an assertion about readability alone would
* have reported success about a control that was no longer there.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import {
restorePersistedQueryCache,
startPersistingQueryCache,
} from "@/lib/pwa/query-persister";
import { queryKeys } from "@/lib/query-keys";
import {
__resetRecordScopeForTests,
setRecordScope,
} from "@/lib/query-keys/record-scope";
const BUILD = "test-build";
// ── A minimal in-memory IndexedDB ────────────────────────────────────────────
//
// Only the three calls the persister makes: `open`, `get`, `put`/`delete`
// inside a transaction. Small enough to read in one sitting, real enough that
// the assertions below are about stored bytes rather than about call counts.
const store = new Map<string, unknown>();
function fakeRequest<T>(result: T) {
const req: {
result: T;
onsuccess: (() => void) | null;
onerror: (() => void) | null;
onupgradeneeded: (() => void) | null;
} = { result, onsuccess: null, onerror: null, onupgradeneeded: null };
queueMicrotask(() => req.onsuccess?.());
return req;
}
function fakeObjectStore() {
return {
get: (key: string) => fakeRequest(store.get(key)),
put: (value: unknown, key: string) => {
store.set(key, value);
return fakeRequest(undefined);
},
delete: (key: string) => {
store.delete(key);
return fakeRequest(undefined);
},
};
}
function installFakeIndexedDb() {
(globalThis as { indexedDB?: unknown }).indexedDB = {
open: () => {
const req: {
result: unknown;
onsuccess: (() => void) | null;
onerror: (() => void) | null;
onupgradeneeded: (() => void) | null;
} = {
result: {
createObjectStore: () => {},
close: () => {},
transaction: () => {
const tx: {
objectStore: () => ReturnType<typeof fakeObjectStore>;
oncomplete: (() => void) | null;
onerror: (() => void) | null;
} = {
objectStore: fakeObjectStore,
oncomplete: null,
onerror: null,
};
queueMicrotask(() => tx.oncomplete?.());
return tx;
},
},
onsuccess: null,
onerror: null,
onupgradeneeded: null,
};
queueMicrotask(() => req.onsuccess?.());
return req;
},
};
}
/** Let the persister's microtask chains and its 1 s debounce run. */
async function settle(): Promise<void> {
await vi.advanceTimersByTimeAsync(1500);
}
const originalIdb = (globalThis as { indexedDB?: unknown }).indexedDB;
beforeEach(() => {
vi.useFakeTimers();
store.clear();
__resetRecordScopeForTests();
installFakeIndexedDb();
});
afterEach(() => {
vi.useRealTimers();
if (originalIdb === undefined) {
delete (globalThis as { indexedDB?: unknown }).indexedDB;
} else {
(globalThis as { indexedDB?: unknown }).indexedDB = originalIdb;
}
});
function appClient(): QueryClient {
return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS });
}
describe("persisting while inside another record", () => {
it("writes the dashboard to disk in the caller's own record", async () => {
// The positive control, and the reason the refusal below means anything:
// without it, "the store is empty" and "the persister does nothing at all
// in this test harness" are the same sentence.
const client = appClient();
const stop = startPersistingQueryCache(client, BUILD);
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
await settle();
stop();
expect(store.size).toBeGreaterThan(0);
});
it("writes nothing to disk while inside another record", async () => {
setRecordScope("acct-owner");
const client = appClient();
const stop = startPersistingQueryCache(client, BUILD);
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 92 });
await settle();
stop();
// Not "a smaller payload" and not "the wrong key" — nothing. The
// allowlist decides which families are low-sensitivity enough to keep on
// this device, and that judgement was made about the account's own data.
expect(store.size).toBe(0);
});
});
describe("restoring while inside another record", () => {
/** Seed the store the way a session in its own record would have left it. */
async function seedOwnSnapshot(): Promise<void> {
const writer = appClient();
const stop = startPersistingQueryCache(writer, BUILD);
writer.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
await settle();
stop();
expect(store.size).toBeGreaterThan(0);
}
it("hydrates the persisted dashboard in the caller's own record", async () => {
await seedOwnSnapshot();
const reader = appClient();
await restorePersistedQueryCache(reader, BUILD);
await vi.advanceTimersByTimeAsync(0);
expect(reader.getQueryData(queryKeys.dashboardSnapshot())).toEqual({
weightKg: 71,
});
});
it("restores nothing while inside another record", async () => {
await seedOwnSnapshot();
setRecordScope("acct-owner");
const reader = appClient();
await restorePersistedQueryCache(reader, BUILD);
await vi.advanceTimersByTimeAsync(0);
// The disk holds the DELEGATE's own dashboard. Painting it under a banner
// saying "you are in her record" is the mis-context failure this feature
// exists to prevent, in the one place a full reload cannot clear.
expect(reader.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
// And the entry is not merely unreachable — it was never hydrated. The
// line above passes with the guard deleted, because the record-scoped
// hash already makes a snapshot written in one record unreadable in
// another; asserting only that would be a check that cannot fail for the
// control it names. This one counts what landed in the cache, so removing
// the guard leaves exactly one entry here and turns it red.
expect(reader.getQueryCache().getAll()).toHaveLength(0);
});
});
+10
View File
@@ -1,5 +1,7 @@
import type { DefaultOptions, QueryClient } from "@tanstack/react-query";
import { recordScopedQueryKeyHashFn } from "@/lib/query-keys/record-scope";
export const MEANINGFUL_HIDDEN_INTERVAL_MS = 60_000;
interface VisibilityTarget {
@@ -57,6 +59,13 @@ export function subscribeToMeaningfulVisibilityRefresh(
* regardless, the offline `fetch` rejects immediately, and `onError` fires — an
* honest failure the form surfaces (and the global `OfflineMutationToaster`
* backstops), instead of a spinner-of-death plus silent write loss.
*
* v1.36.0 — `queryKeyHashFn` folds the record being read into the cache
* identity of every entry in the app. Without it `["dashboard","snapshot"]`
* names two different people's data and the cache cannot tell them apart. See
* `src/lib/query-keys/record-scope.ts` for why this sits on the hash rather
* than on the key arrays (the arrays feed prefix invalidation and the
* import-time dependent-key bundles, both of which would break).
*/
export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = {
queries: {
@@ -64,6 +73,7 @@ export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = {
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
queryKeyHashFn: recordScopedQueryKeyHashFn,
},
mutations: {
networkMode: "always",
+37
View File
@@ -33,6 +33,7 @@ import {
} from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
const DB_NAME = "healthlog-query-cache";
const STORE = "kv";
@@ -173,6 +174,12 @@ export async function restorePersistedQueryCache(
buildVersion: string,
): Promise<void> {
if (typeof indexedDB === "undefined") return;
// v1.36.0 — a browser inside somebody else's record restores nothing. The
// store holds the caller's OWN dashboard (it is the only thing that was ever
// written to it, see the flush below), and painting that under a banner
// saying "viewing her record" is the mis-context failure this feature is
// built to prevent, in the one place a full reload cannot clear.
if (isReadingSharedRecord()) return;
try {
const payload = await idbGet<PersistedPayload>();
if (!payload) return;
@@ -245,6 +252,13 @@ export function startPersistingQueryCache(
let timer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
// v1.36.0 — never write another person's record to this device. The
// allowlist below decides WHICH families are low-sensitivity enough to
// persist; that judgement was made about the account's own data and does
// not carry over to a record they were merely given to read. Refusing at
// the flush (rather than filtering keys) also means the last good snapshot
// of the caller's own dashboard survives a switch untouched.
if (isReadingSharedRecord()) return;
const state = dehydrate(queryClient, {
shouldDehydrateQuery: (query) =>
query.state.status === "success" && isPersistableKey(query.queryKey),
@@ -338,3 +352,26 @@ export async function clearOfflineCachesForSessionEnd(): Promise<void> {
}
}
}
/**
* v1.36.0 — the same wipe, before a record switch in either direction.
*
* Deliberately the same bytes as the session-end wipe rather than a narrower
* one, and deliberately named for the act that calls it.
*
* The full page reload the switch performs clears the in-memory cache; these
* two layers are the ones that survive it. The IndexedDB snapshot holds the
* dashboard the browser was last looking at, and the service worker's
* `healthlog-data-*` cache answers `/api/…` reads from disk on a
* stale-while-revalidate branch — so without this call, the first paint after
* switching into a record can be the previous record's numbers, served by the
* worker, under a banner naming the new one. Nothing about the page's own code
* would be wrong; the answer would just come from somewhere the page does not
* control.
*
* Run it BEFORE the reload and await it. A wipe racing the navigation is a
* wipe that sometimes does not happen, and "sometimes" is the whole failure.
*/
export async function clearOfflineCachesForRecordSwitch(): Promise<void> {
await clearOfflineCachesForSessionEnd();
}
@@ -0,0 +1,81 @@
/**
* A grant transition refreshes everything it can have changed.
*
* The standing rule this checks is not "the mutation calls invalidate" — that
* is a claim about the source. It is "the three reads a grant transition makes
* stale come back invalidated", and the only thing that can answer it is a
* real `QueryClient` with those three reads in it.
*
* The third one is the interesting one. The grant list and the activity feed
* are the surface a person is looking at; `["auth","me"]` is the account
* payload, which carries `accountAccess` — the switcher's menu and the banner.
* Forget it and a revoked record keeps its entry in the menu: the affordance
* still on screen, the click already refused by the server.
*
* Mutation check, run: drop `queryKeys.authMe()` from `grantDependentKeys`
* and "refreshes the account payload" goes red.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
import { queryKeys } from "@/lib/query-keys";
import { __resetRecordScopeForTests } from "@/lib/query-keys/record-scope";
beforeEach(() => {
__resetRecordScopeForTests();
});
function seeded(): QueryClient {
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
client.setQueryData(queryKeys.accountGrants(), { given: [], received: [] });
client.setQueryData(queryKeys.accountActivity(), {
entries: [],
retentionDays: 365,
});
client.setQueryData(queryKeys.authMe(), { id: "u1" });
return client;
}
describe("invalidateGrantReads", () => {
it("refreshes the grant list", () => {
const client = seeded();
invalidateGrantReads(client);
expect(
client.getQueryCache().find({ queryKey: queryKeys.accountGrants() })
?.state.isInvalidated,
).toBe(true);
});
it("refreshes the owner's record-activity feed", () => {
const client = seeded();
invalidateGrantReads(client);
expect(
client.getQueryCache().find({ queryKey: queryKeys.accountActivity() })
?.state.isInvalidated,
).toBe(true);
});
it("refreshes the account payload, which carries the switcher", () => {
const client = seeded();
invalidateGrantReads(client);
expect(
client.getQueryCache().find({ queryKey: queryKeys.authMe() })?.state
.isInvalidated,
).toBe(true);
});
it("starts from three live, valid entries", () => {
// Non-zero proof. Without this, a client that failed to seed and a client
// whose entries were all invalidated look identical from the assertions
// above — every `find()` would return undefined and `?.state` would make
// each one a quiet `undefined !== true`.
const client = seeded();
const all = client.getQueryCache().getAll();
expect(all).toHaveLength(3);
expect(all.every((q) => q.state.isInvalidated)).toBe(false);
});
});
+167
View File
@@ -0,0 +1,167 @@
"use client";
/**
* v1.36.0 — the reads and writes behind the shared-access panel.
*
* One module for the whole surface, because every mutation here invalidates
* the same three things and the standing rule about cache invalidation is
* easiest to keep when there is one place that can break it. `grantDependentKeys`
* (`@/lib/query-keys`) is that place: the grant list, the owner's activity
* feed, and the account payload — the last because `accountAccess` is what
* paints the switcher and the banner, and a revoke that refreshed the panel but
* not the payload leaves a switcher entry pointing at a record the server has
* just closed.
*
* Nothing in here decides anything. `state` arrives resolved from the same
* function the request resolver's own predicate is built on, so the panel
* cannot render "active" on a row the server has stopped honouring.
*/
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { grantDependentKeys, queryKeys } from "@/lib/query-keys";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
id: string;
username: string;
displayName: string | null;
}
export type GrantState = "PENDING" | "ACTIVE" | "EXPIRED" | "REVOKED";
export interface GrantRow {
id: string;
account: GrantParty;
access: "READ" | "WRITE";
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
expiresAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
revokedBy: "GRANTOR" | "GRANTEE" | null;
}
export interface GrantList {
/** Grants this account has offered on its own record. */
given: GrantRow[];
/** Grants offered to this account on somebody else's. */
received: GrantRow[];
}
export interface RecordActivityEntry {
id: string;
actor: GrantParty | null;
action: string;
accesses: number | null;
createdAt: string;
}
export interface RecordActivity {
entries: RecordActivityEntry[];
/**
* How far back the feed can see, in days — resolved by the server from the
* same setting the purge job reads. The view states it rather than implying
* the list is everything that ever happened.
*/
retentionDays: number;
}
export function useAccountGrants(enabled = true) {
return useQuery({
queryKey: queryKeys.accountGrants(),
queryFn: () => apiGet<GrantList>("/api/account/grants"),
enabled,
});
}
export function useRecordActivity(enabled = true) {
return useQuery({
queryKey: queryKeys.accountActivity(),
queryFn: () => apiGet<RecordActivity>("/api/account/activity"),
enabled,
});
}
/**
* Refresh everything a grant transition can have changed.
*
* A plain function over a QueryClient rather than a hook body, so a test can
* hand it a real client, seed the three reads and check they came back
* invalidated — which is the only way to tell an invalidation that runs from
* one that was written and never wired.
*/
export function invalidateGrantReads(queryClient: QueryClient): void {
for (const key of grantDependentKeys) {
void queryClient.invalidateQueries({ queryKey: key });
}
}
/**
* The same refresh, bound to the mounted client.
*
* Shared by all four mutations rather than written out per hook: the day one
* of them forgets the account payload is the day a revoked record keeps its
* switcher entry.
*/
function useInvalidateGrants() {
const queryClient = useQueryClient();
return () => invalidateGrantReads(queryClient);
}
export interface InviteInput {
identifier: string;
/** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */
expiresAt: string | null;
}
export function useInviteGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantInvite(),
mutationFn: (input: InviteInput) =>
apiPost<GrantRow>("/api/account/grants", input),
onSuccess: invalidate,
});
}
export function useAcceptGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantAccept(),
mutationFn: (grantId: string) =>
apiPost<GrantRow>(`/api/account/grants/${grantId}/accept`),
onSuccess: invalidate,
});
}
export function useRevokeGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantRevoke(),
mutationFn: (grantId: string) =>
apiDelete<GrantRow & { sessionsCleared: number }>(
`/api/account/grants/${grantId}`,
),
onSuccess: invalidate,
});
}
export function useRenounceGrant() {
const invalidate = useInvalidateGrants();
return useMutation({
mutationKey: queryKeys.accountGrantRenounce(),
mutationFn: (grantId: string) =>
apiPost<GrantRow & { sessionsCleared: number }>(
`/api/account/grants/${grantId}/renounce`,
),
onSuccess: invalidate,
});
}
@@ -0,0 +1,154 @@
/**
* The account dimension of the query cache.
*
* These run against a REAL `QueryClient` configured with the app's own default
* options, not against the hash function in isolation. The question worth
* answering is not "does the string change" — it is "can a value written while
* reading one record be read back while reading another", and only the cache
* itself can answer that.
*
* Mutation check, verified: delete `queryKeyHashFn` from
* `QUERY_CLIENT_DEFAULT_OPTIONS` and the two cross-record leaks below go red
* with the previous record's data returned verbatim.
*/
import { beforeEach, describe, expect, it } 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,
getRecordScope,
isReadingSharedRecord,
recordScopedQueryKeyHashFn,
setRecordScope,
} from "@/lib/query-keys/record-scope";
function appClient(): QueryClient {
return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS });
}
beforeEach(() => {
__resetRecordScopeForTests();
});
describe("the record scope", () => {
it("starts in the caller's own record", () => {
expect(getRecordScope()).toBeNull();
expect(isReadingSharedRecord()).toBe(false);
});
it("moves, and comes back", () => {
setRecordScope("acct-owner");
expect(getRecordScope()).toBe("acct-owner");
expect(isReadingSharedRecord()).toBe(true);
setRecordScope(null);
expect(getRecordScope()).toBeNull();
expect(isReadingSharedRecord()).toBe(false);
});
});
describe("the hash function", () => {
it("is TanStack's own hash while reading the caller's own record", () => {
// The overwhelming majority of sessions never switch. Nothing about their
// cache identity may change, or every persisted entry in the field is
// orphaned by this release.
expect(recordScopedQueryKeyHashFn(["dashboard", "snapshot"])).toBe(
JSON.stringify(["dashboard", "snapshot"]),
);
});
it("sorts object keys, like the hash it replaces", () => {
// TanStack's `hashKey` is stable across key order inside an object so two
// spellings of one filter share a slot. Losing that would fragment the
// cache in a way no test of ours would otherwise notice.
const a = recordScopedQueryKeyHashFn(["k", { b: 2, a: 1 }]);
const b = recordScopedQueryKeyHashFn(["k", { a: 1, b: 2 }]);
expect(a).toBe(b);
});
it("gives one key two identities in two records", () => {
const own = recordScopedQueryKeyHashFn(queryKeys.dashboardSnapshot());
setRecordScope("acct-owner");
const shared = recordScopedQueryKeyHashFn(queryKeys.dashboardSnapshot());
expect(shared).not.toBe(own);
});
});
describe("the cache cannot serve one record's data while reading another", () => {
it("hides an entry written in the caller's own record", () => {
const client = appClient();
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({
weightKg: 71,
});
setRecordScope("acct-owner");
// The delegate's own numbers must be unreachable from inside somebody
// else's record. This is the assertion the whole hash exists for.
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
});
it("hides an entry written in a shared record once the browser leaves", () => {
const client = appClient();
setRecordScope("acct-owner");
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 92 });
setRecordScope(null);
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
});
it("keeps two records' entries apart at the same time", () => {
const client = appClient();
setRecordScope("acct-a");
client.setQueryData(queryKeys.dashboardSnapshot(), { record: "a" });
setRecordScope("acct-b");
client.setQueryData(queryKeys.dashboardSnapshot(), { record: "b" });
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({
record: "b",
});
setRecordScope("acct-a");
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({
record: "a",
});
});
});
describe("prefix invalidation still reaches every slot", () => {
it("invalidates by key ARRAY, which the hash never touched", () => {
// The reason the dimension lives on the hash and not on the key arrays.
// `measurementDependentKeys` and every other bundle in the factory were
// built at import time, before any record was known; had the arrays grown
// a scope element, none of them would match anything ever again — and
// nothing would have failed, the caches would simply have stopped
// refreshing after a write.
const client = appClient();
setRecordScope("acct-owner");
client.setQueryData(queryKeys.measurementsList(LIST_PARAMS), [1, 2, 3]);
client.invalidateQueries({ queryKey: queryKeys.measurements() });
const entry = client
.getQueryCache()
.find({ queryKey: queryKeys.measurementsList(LIST_PARAMS) });
expect(entry).toBeDefined();
expect(entry?.state.isInvalidated).toBe(true);
});
});
const LIST_PARAMS = {
type: undefined,
sourceEq: undefined,
from: undefined,
to: undefined,
valueMin: undefined,
valueMax: undefined,
page: 1,
sortBy: "measuredAt",
sortDir: "desc",
mode: "raw",
} as const;
+18
View File
@@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
import { nutrientKeys } from "./nutrients";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { workoutKeys } from "./workouts";
export const queryKeys = {
@@ -62,8 +63,25 @@ export const queryKeys = {
...documentKeys,
...customMetrics,
...nutrientKeys,
...sharingKeys,
};
/**
* v1.36.0 — every read a grant transition can make stale.
*
* Inviting, accepting, revoking and renouncing all change the same three
* answers: the grant list, the owner's record-activity feed, and
* `accountAccess` on the account payload — which is what drives the switcher
* and the banner. A revoke that refreshed the panel but not the payload would
* leave a switcher entry pointing at a record the server had just closed: the
* affordance still on screen, the click already refused.
*/
export const grantDependentKeys = [
queryKeys.accountGrants(),
queryKeys.accountActivity(),
queryKeys.authMe(),
];
/**
* Keys that should be invalidated when a measurement is created, updated or
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
+156
View File
@@ -0,0 +1,156 @@
/**
* v1.36.0 — the account dimension of the query cache.
*
* Every cached read in this app is about one health record. Nothing in the key
* factory says which one, because until now there was only ever one: the
* signed-in account's. Once a browser can act on somebody else's record, a key
* like `["dashboard","snapshot"]` names two different answers, and a cache that
* cannot tell them apart will hand the second reader the first one's data.
* That is this repository's known cache-poison class, on health data, across
* two people.
*
* The fix is one line at the root rather than a dimension threaded through
* twenty-three key modules: a `queryKeyHashFn` on the QueryClient. The hash is
* what the cache stores entries under and what `getQueryData` looks them up by,
* so folding the record id into it partitions every entry in the app at once.
* Key ARRAYS are untouched, which matters more than it looks: prefix
* invalidation (`invalidateQueries({ queryKey: ["measurements"] })`) and the
* module-scope dependent-key bundles in `./index.ts` both match on the array,
* were computed at import time before any record was known, and keep working
* unchanged. Prefixing the arrays instead would have silently broken every one
* of those bundles.
*
* ## Why this is defence in depth and not the primary control
*
* Switching records is a full page reload (design §2), so the in-memory cache
* is empty on the other side of it and this hash can only matter if that ever
* stops being true. The primary control for the layers that DO survive a
* reload — the IndexedDB snapshot and the service worker's data cache — is the
* wipe the switch performs before reloading, in `switchRecord()`. Both are
* cheap; only one of them is the reason the feature is safe.
*
* ## Why the mirror is in localStorage
*
* The scope has to be known synchronously at boot, before `/api/auth/me`
* answers, because the persister decides whether to restore from disk in the
* first effect after mount. A switch is a property of the SESSION — it is
* carried on the session row, not per tab — so every tab of the browser is
* switched at once, which is what localStorage models and sessionStorage does
* not.
*
* A mirror can go stale, and the honest question is what a stale one costs.
* Nothing, because the disk layers are wiped on every switch in both
* directions: a mirror claiming a record the session has left points the
* persister at a store that was emptied when it left, and a mirror claiming
* "own" while the session is inside a record points it at a store that was
* emptied on the way in and never written to since (the persister refuses to
* write while switched — see `isPersistableKey`'s caller). Either way the
* worst case is an empty restore and a skeleton, never another person's data.
* `fetchMe` re-stamps the mirror from the server's resolved answer on every
* boot, so the staleness window is one round trip.
*/
import type { QueryKey } from "@tanstack/react-query";
const STORAGE_KEY = "healthlog-record-scope";
/**
* The in-process copy. Seeded from storage on first read so the hash function
* — which runs on every single query — never touches localStorage.
*/
let scope: string | null = null;
let seeded = false;
function readMirror(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === null || raw.length === 0 ? null : raw;
} catch {
// Private mode, disabled storage, or SSR. "Own record" is the safe
// reading: it is what every browser reported before this feature existed.
return null;
}
}
/**
* The record this browser is currently reading, or null when it is reading its
* own.
*
* Server-side this stays null for the life of the process: the mirror is only
* ever seeded from browser storage, and `setRecordScope` is called from client
* code alone. That matters more than it looks — the value is a module-level
* variable, so a server that could write it would be sharing one scope across
* every request it serves.
*/
export function getRecordScope(): string | null {
if (!seeded) {
scope = typeof window === "undefined" ? null : readMirror();
seeded = true;
}
return scope;
}
/**
* Point the cache at a record, or (with `null`) back at the caller's own.
*
* Called from exactly two places: the switch flow, before it reloads the page,
* and `fetchMe`, which corrects the mirror from the server's resolved
* `accountAccess.active` on every account read. The second is what keeps a
* mirror written by a switch that the server has since ended from surviving
* past one round trip.
*/
export function setRecordScope(accountId: string | null): void {
scope = accountId;
seeded = true;
if (typeof window === "undefined") return;
try {
if (accountId === null) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, accountId);
}
} catch {
/* storage unavailable — the in-process copy still partitions this tab */
}
}
/** True while this browser is reading somebody else's record. */
export function isReadingSharedRecord(): boolean {
return getRecordScope() !== null;
}
/**
* The QueryClient's key hash function.
*
* Identical to TanStack's own `hashKey` when the browser is in its own record
* — a `JSON.stringify` with sorted object keys — so nothing about the app's
* cache changes for the overwhelming majority of sessions. Inside a shared
* record every entry lands under a distinct hash, which is what makes an
* inherited entry unreachable rather than merely stale.
*/
export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string {
const key = JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce<Record<string, unknown>>((result, k) => {
result[k] = (val as Record<string, unknown>)[k];
return result;
}, {})
: val,
);
const record = getRecordScope();
return record === null ? key : `record:${record}|${key}`;
}
/** TanStack's own predicate, copied rather than imported — it is not exported. */
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (Object.prototype.toString.call(value) !== "[object Object]") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
/** @internal — lets a test start from a known state. */
export const __resetRecordScopeForTests = (): void => {
scope = null;
seeded = false;
};
+37
View File
@@ -0,0 +1,37 @@
/**
* Query keys — account sharing (v1.36.0).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* Note what these keys deliberately do NOT carry: the acting account. That
* dimension lives on the QueryClient's `queryKeyHashFn`
* (`./record-scope.ts`), which folds it into every entry in the app at once
* — including these. Adding it here as well would partition the same cache
* twice under two rules, which is the two-deciders shape in miniature.
*
* The reads below are all non-delegable by construction: `/api/account/grants`
* and the record-activity feed both resolve through a bare `requireAuth()` and
* refuse while a switch is on. They are still record-scoped like everything
* else, so a session that switches out and back never reads a grant list
* cached under the wrong scope.
*/
export const sharingKeys = {
/**
* Grants in both directions (`GET /api/account/grants`). One key for one
* endpoint: the route answers "who can see my record, and whose can I see"
* in a single payload precisely so no client renders half of it.
*/
accountGrants: () => ["account", "grants"] as const,
/**
* The owner's record-activity feed (`GET /api/account/activity`) — who
* opened their record, and when.
*/
accountActivity: () => ["account", "activity"] as const,
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
accountGrantInvite: () => ["account", "grants", "invite"] as const,
accountGrantAccept: () => ["account", "grants", "accept"] as const,
accountGrantRevoke: () => ["account", "grants", "revoke"] as const,
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
accountSwitchMutation: () => ["account", "switch"] as const,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no imports at all, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
/** One account this caller may act on. */
export interface AccountAccessEntry {
/** The account whose record it is — the value `POST /api/account/switch` takes. */
accountId: string;
username: string;
displayName: string | null;
/** The grant's level. `"read"` throughout v1. */
access: "read" | "write";
/** May this caller change that record. Resolved server-side; `false` in v1. */
canWrite: boolean;
}
/** The `accountAccess` block on `GET /api/auth/me`. */
export interface AccountAccess {
/** Every account this caller may open, newest grant first. */
accounts: AccountAccessEntry[];
/**
* The record this browser is inside right now, resolved to a full entry —
* not an id the client has to look up. Null when it is in its own record,
* and always null on the Bearer transport.
*/
active: AccountAccessEntry | null;
/** Is there anywhere to switch to. */
canSwitch: boolean;
}
/**
* The name to put in front of a person, given what the payload published.
*
* `displayName` when the account set one, the username otherwise. This is a
* presentation fallback and not a derivation of anything the server decided —
* both values are published, and which of the two reads better is the client's
* business. Kept here so the banner, the switcher and the sharing panel cannot
* disagree about what to call somebody.
*/
export function accountLabel(entry: {
displayName: string | null;
username: string;
}): string {
const named = entry.displayName?.trim();
return named && named.length > 0 ? named : entry.username;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
*
* This module answers three questions for one caller, once per `/api/auth/me`:
* which records may I open, which one am I inside right now, and what may I do
* in it. Every answer is a VALUE, not an input to a client-side calculation.
*
* That is the whole design of this file and it is worth stating plainly,
* because the tempting alternative is one line shorter: publish the grant rows
* and let the client work out what they mean. Then two programs decide one
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
* client is being read today — and they answer differently the first time an
* expiry, a revocation or a second access level appears. The client renders
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
* the payload it could compute them from.
*
* `canWrite` is `false` for every entry in v1, because `inviteGrant` writes
* READ and only READ. It is resolved here through {@link grantAllows} against
* the real row rather than hardcoded, so the day a WRITE grant exists the
* answer changes without this file being edited — and, more importantly, so
* that nobody has to remember that it should.
*
* What is deliberately NOT published: the other account's avatar. The avatar
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
* caller's own), so a URL here would resolve to a 403 and paint a broken
* image. Opening that route to a delegate is a classification decision about
* a route, not a decision the account payload gets to make on the side.
* Clients paint the initials fallback they already have.
*/
import { prisma } from "@/lib/db";
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
/**
* Resolve the sharing block for one caller.
*
* The block is always present, even for an account with no grants in either
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
* shared anything with you", which is a different statement from a missing
* field (which says "this server does not know about sharing"). Absence reads
* as absence.
*
* Takes the whole auth context rather than a pair of ids, so the carrier column
* is named here and in no route file — the boundary guard in
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
* on purpose, and a field plucked out at every call site is how such a list
* grows one entry per surface.
*
* The session's stamp is deliberately NOT trusted to mean access: an entry
* appears in `active` only if the same pass that
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
* between two requests reads as "not switched" here, which is exactly what the
* request resolver will independently decide about the next delegated read.
* The client's job on seeing that mismatch is to clear the stamp, not to
* reason about it.
*/
export async function resolveAccountAccess(auth: {
user: { id: string };
session: { actingAsUserId?: string | null };
}): Promise<AccountAccess> {
const actorId = auth.user.id;
const stamped = auth.session.actingAsUserId ?? null;
const now = new Date();
// Narrow in SQL on the one condition the partial unique index makes cheap,
// and let the state machine decide the rest — the same split
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
// out "accepted and not expired" beside a TypeScript one is two places
// deciding one question.
const rows = await prisma.accountGrant.findMany({
where: { granteeId: actorId, revokedAt: null },
orderBy: { createdAt: "desc" },
include: {
grantor: { select: { id: true, username: true, displayName: true } },
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => ({
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: grant.access === "WRITE" ? "write" : "read",
canWrite: grantAllows(grant, "write", now),
}));
const active =
stamped === null
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return { accounts, active, canSwitch: accounts.length > 0 };
}