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); + }); +});