From 0cccb4de99a375c05a7482c5d49299dec057707e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 01:54:29 +0200 Subject: [PATCH] fix(sharing): keep a scoped grant out of the record's module configuration Publishing the active record's module map and cycle flag on the account payload was right for a whole-record grant and too generous for a narrowed one. `accountAccess.active` is set for any live grant, level and scope included, so a delegate scoped to `measurements` read whether the owner tracks their cycle, their screeners, their illness episodes and their supplement intake, and the cycle flag derives from the owner's recorded sex. Both fields are now masked to the sections the active grant names: a closed section answers `false`, which is the answer a module that is off already gives and the answer the browser's route inventory has always acted on. An unscoped grant names the whole record and is unaffected, so the own-record and whole-record payloads are byte-identical to what they were. The masking table lives beside the sharing vocabulary rather than inside it, and runs one way only: no value in it can turn a module on, admit a read or widen a grant. --- src/app/api/auth/me/route.ts | 59 +++++++--- src/lib/sharing/module-disclosure.ts | 104 ++++++++++++++++++ .../managed-record-modules.test.ts | 74 ++++++++++++- 3 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 src/lib/sharing/module-disclosure.ts diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index a278da185..8f4b63a3c 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -27,9 +27,19 @@ * settings wrote a column nothing on screen was reading. Design §4 is about * whose eyes are on the screen; this is about whose record is on it. * + * Those two fields are then MASKED to the sections the active grant names. + * `accountAccess.active` is set for any live grant, level and scope included, + * so the record scoping on its own would hand a delegate scoped to + * `measurements` the owner's cycle, screener, illness and nutrient + * configuration — and the cycle flag is a derivation of the owner's recorded + * sex. A section the grant does not open answers `false`, which is the answer + * a module that is off already gives and the answer the browser's own route + * inventory has always acted on. An unscoped grant names every section and is + * unaffected. + * * The own-record case is byte-identical: with no switch the active record IS - * the caller, and every value below is resolved from the same row it always - * was. + * the caller, there is no grant to narrow to, and every value below is + * resolved from the same row it always was. */ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireActorAuth } from "@/lib/api-handler"; @@ -46,6 +56,10 @@ import { } from "@/lib/modules/gate"; import { parseTourProgress } from "@/lib/onboarding/tour-progress"; import { resolveAccountAccess } from "@/lib/sharing/account-access"; +import { + maskModulesToSections, + sectionsOpen, +} from "@/lib/sharing/module-disclosure"; import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; export const dynamic = "force-dynamic"; @@ -87,8 +101,13 @@ export const GET = apiHandler(async () => { // revoked record's configuration would be paid once, by somebody else. const recordId = accountAccess.active?.accountId ?? user.id; - const [record, cycleProfile, modules, moduleAvailability] = await Promise.all( - [ + // What the grant opens, and therefore what the two record fields below may + // say. `null` is the whole record — the answer for an unscoped grant and for + // no switch at all, and the reason the own-record payload is untouched. + const sections = accountAccess.active?.sections ?? null; + + const [record, cycleProfile, resolvedModules, moduleAvailability] = + await Promise.all([ // The actor's own row is already in hand; a switched session needs the // record's `gender`, which is the column the cycle gate derives from. recordId === user.id @@ -115,12 +134,24 @@ export const GET = apiHandler(async () => { // Server-WIDE, so it is the same map for every record and is not // re-scoped here. getOperatorModuleAvailability(), - ], - ); - const cycleTrackingEnabled = isCycleEnabled( - record?.gender ?? null, - cycleProfile, - ); + ]); + + // The masking step, and the one place the record scoping is narrowed rather + // than resolved. `active` is set for ANY live grant — a READ grant scoped to + // `measurements` included — so publishing the record's whole map would tell + // a delegate whether the owner tracks their cycle, their screeners, their + // illness episodes or their supplement intake, none of which their grant + // opens. A closed section reads `false`, the same answer a module that is + // off already gives, and the navigation the client builds from it drops + // exactly the doors `isSharedRecordPathPresentable` drops anyway. + const modules = maskModulesToSections(resolvedModules, sections); + // The cycle flag is masked on its own rather than read off `modules.cycle`: + // that key also carries the operator's server-wide availability, and this + // field never has. Same section, same answer, without borrowing a second + // layer's decision. + const cycleTrackingEnabled = + sectionsOpen(sections, "cycle") && + isCycleEnabled(record?.gender ?? null, cycleProfile); // v1.7.0 — patient-identity fields for the health-record export. The // KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on @@ -199,8 +230,9 @@ export const GET = apiHandler(async () => { // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS // hides the whole cycle tab when this is false. v1.38.14 — resolved for // the ACTIVE RECORD, so a switched browser hides the tab the record does - // not track rather than the one the actor does not (#939). Unchanged on - // the native transport, which carries no switch. + // not track rather than the one the actor does not (#939), and `false` + // whenever the active grant does not name the `cycle` section. Unchanged + // on the native transport, which carries no switch. cycleTrackingEnabled, // v1.18.0 — module enable/disable map. `{ : boolean }` // for every toggleable module; `false` means the module is OFF and @@ -208,7 +240,8 @@ export const GET = apiHandler(async () => { // …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the // resolved `disableCoach` + operator master flag, so this map is the // single thing a client needs to gate every secondary domain. - // v1.38.14 — resolved for the ACTIVE RECORD; see the module docblock. + // v1.38.14 — resolved for the ACTIVE RECORD and masked to the sections + // the active grant opens; see the module docblock. modules, // v1.18.0 — operator-layer availability per toggleable module. `false` // ⇒ the operator turned the module off server-wide (off for every diff --git a/src/lib/sharing/module-disclosure.ts b/src/lib/sharing/module-disclosure.ts new file mode 100644 index 000000000..51a1b51a3 --- /dev/null +++ b/src/lib/sharing/module-disclosure.ts @@ -0,0 +1,104 @@ +/** + * What a scoped grant is allowed to learn about a record's configuration. + * + * v1.38.14 made `GET /api/auth/me` publish the ACTIVE RECORD's module map and + * cycle flag rather than the actor's, because every surface those two fields + * gate shows the record's data. That is right for a whole-record grant and too + * generous for a narrowed one: `accountAccess.active` is set for any live + * grant, level and scope included, so a delegate scoped to `measurements` + * would read whether the owner tracks their cycle, their mental-health + * screeners, their illness episodes and their supplement intake — configuration + * the grant does not open, and in the cycle flag's case a derivation of the + * owner's recorded sex. + * + * So the map is masked to the sections the grant names, and the mask is the + * reason this table can exist beside `scope.ts`'s standing rule that the + * sharing vocabulary never converts to the module vocabulary. The rule guards + * a direction: a module the owner enabled must never imply a section the owner + * shared. Nothing here runs that way. A closed section answers `false` — the + * same answer the client already gets for a module that is off — and an open + * one answers whatever the gate resolved, never more. No value in this file + * can turn a module ON, admit a read, or widen a grant; deleting the table + * would leak, not lock. + * + * `null` means "this module is not one section of a record". Those keys are + * masked off for every scoped grant, which is the same answer the browser's + * own route inventory gives them: `/`, `/achievements` and the narrative + * surfaces read across sections, and a selected-domain grant never opens one. + */ +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +/** + * The section of a record each module's surfaces read, or `null` when they + * read across sections. + * + * Typed as a total record over {@link MODULE_KEYS}, so a new module cannot + * ship without an answer here — and the answer a hurried one gives is at worst + * `null`, which discloses nothing. + * + * The section chosen is the one the browser's route inventory already puts the + * module's page under (`src/lib/navigation/shared-record.ts`), so the chrome a + * delegate is offered and the configuration they are told about agree. + */ +export const MODULE_SHARE_DOMAIN: Readonly< + Record +> = Object.freeze({ + cycle: "cycle", + mood: "mind", + sleep: "measurements", + glucose: "measurements", + workouts: "measurements", + recovery: "measurements", + labs: "labs", + illness: "illness", + // Reads across the record: the achievement engine counts every domain. + achievements: null, + // The assistant surfaces narrate the whole record, which is why a scoped + // grant is never offered them. + coach: null, + insights: null, + medications: "medications", + // The export assembles every section it is given. + doctorReport: null, + // Weather and air quality are context for the record as a whole, not one + // section of it. + environment: null, + // The account's own remote endpoint. Not a section of anybody's record. + mcp: null, + inboundDocuments: "documents", + mentalHealth: "mind", + nutrients: "measurements", + // An immunization history is health background, the section `/vaccinations` + // is presented under. + vaccinations: "profile", +}); + +/** Does a grant carrying these sections open this one? `null` is the whole record. */ +export function sectionsOpen( + sections: readonly ShareDomain[] | null, + domain: ShareDomain, +): boolean { + return sections === null || sections.includes(domain); +} + +/** + * The module map as a grant carrying these sections may read it. + * + * `null` sections are the whole record and pass the map through untouched, + * which keeps the own-record and whole-record payloads byte-identical to what + * they were. Anything else answers `false` for every key outside the grant. + */ +export function maskModulesToSections( + modules: Record, + sections: readonly ShareDomain[] | null, +): Record { + if (sections === null) return modules; + const masked = {} as Record; + for (const key of MODULE_KEYS) { + const domain = MODULE_SHARE_DOMAIN[key]; + masked[key] = + domain !== null && sectionsOpen(sections, domain) ? modules[key] : false; + } + return masked; +} diff --git a/tests/integration/managed-record-modules.test.ts b/tests/integration/managed-record-modules.test.ts index 95e7dee0e..9e0b50a32 100644 --- a/tests/integration/managed-record-modules.test.ts +++ b/tests/integration/managed-record-modules.test.ts @@ -24,6 +24,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import type { ShareDomain } from "@/lib/sharing/scope"; + import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; @@ -83,7 +85,9 @@ async function signIn(userId: string) { interface MePayload { modules: Record; cycleTrackingEnabled: boolean; - accountAccess: { active: { accountId: string } | null }; + accountAccess: { + active: { accountId: string; sections: string[] | null } | null; + }; } async function readMe(): Promise { @@ -320,3 +324,71 @@ describe("who may write the record's module map", () => { ); }); }); + +describe("what a SCOPED grant is told about the record's modules", () => { + /** An owner who tracks everything, and a delegate holding one section. */ + async function delegateInside(scope: ShareDomain[] | null) { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants"); + const grant = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope, + }); + await acceptGrant({ grantId: grant.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + return { owner, delegate }; + } + + it("answers false for every module outside the granted sections", async () => { + // The disclosure the record scoping opened: `active` is set for ANY live + // grant, so a delegate holding `measurements` would otherwise read the + // owner's cycle, screener, illness and lab configuration — and the cycle + // flag derives from the owner's recorded sex. + const { owner } = await delegateInside(["measurements"]); + + const payload = await readMe(); + expect(payload.accountAccess.active?.accountId).toBe(owner.id); + expect(payload.accountAccess.active?.sections).toEqual(["measurements"]); + + // The owner really does track these — masked, not merely absent. + const { resolveModuleMap } = await import("@/lib/modules/gate"); + const truth = await resolveModuleMap(owner.id); + for (const key of [ + "cycle", + "mood", + "mentalHealth", + "illness", + "labs", + "medications", + "inboundDocuments", + ] as const) { + expect(truth[key], `the record's own ${key}`).toBe(true); + expect(payload.modules[key], `the delegate's ${key}`).toBe(false); + } + expect(payload.cycleTrackingEnabled).toBe(false); + + // The granted section still answers honestly, or the mask would be a + // blackout rather than a scope. + for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) { + expect(payload.modules[key], `the delegate's ${key}`).toBe(true); + } + }); + + it("leaves an unscoped grant reading the record's true map", async () => { + // `scope: null` is the whole record, which every grant written before + // scoping carries. Masking it would break the case the record scoping was + // built for. + const { owner } = await delegateInside(null); + + const payload = await readMe(); + expect(payload.accountAccess.active?.sections).toBeNull(); + + const { resolveModuleMap } = await import("@/lib/modules/gate"); + expect(payload.modules).toEqual(await resolveModuleMap(owner.id)); + expect(payload.cycleTrackingEnabled).toBe(true); + }); +});