diff --git a/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql b/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql new file mode 100644 index 000000000..d6917254b --- /dev/null +++ b/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql @@ -0,0 +1,40 @@ +-- v1.37.0 — a grant can open part of a record, and a grant can hand it over. +-- +-- Two additive changes, no backfill, no re-consent, and no existing row +-- changes meaning: +-- +-- * `scope_json` is NULL on every row that exists, and NULL means the entire +-- record. That is not a default standing in for a missing answer — it is +-- the answer every one of those rows was actually consented as, before +-- narrowing was a thing anybody could ask for. A non-NULL value is a +-- non-empty array of section keys from the closed vocabulary in +-- `src/lib/sharing/scope.ts`. +-- * `MANAGE` joins the level enum. No row becomes MANAGE here; the value +-- exists so a row can state the level instead of implying it, the same +-- reason `account_grant_access` was a type rather than a boolean from day +-- one (migration 0292). +-- +-- Json rather than a text[] or a join table. A join table would be the +-- normalised answer and it would be the wrong one: the set is read on every +-- delegated request as part of deciding an authorization, so it belongs on the +-- row the resolver already loads, and it is written exactly once, at +-- invitation, by a machine — never edited, never queried across grants. There +-- is no in-place widening anywhere in this feature, so the set has no update +-- path to keep consistent. A text[] would carry the same shape with a +-- Postgres-specific type Prisma models less directly, for no read this +-- product performs. +-- +-- No CHECK constraint asserting the array is non-empty, well-formed, or absent +-- on a MANAGE row. Those three conditions are enforced in +-- `src/lib/sharing/grants.ts`, at the one function that writes the column, and +-- the reason is the same one migrations 0290 and 0292 gave for declining their +-- own CHECKs: the delete-all-data guard seeds a row into every table by +-- walking `information_schema`, and a constraint it cannot satisfy breaks a +-- fixture whose whole value is that nobody hand-writes it. The READ side is +-- what actually protects the record anyway — `normaliseScope` resolves a +-- malformed blob to the empty set and refuses every section, so a bad value +-- that reached the column by any route grants nothing rather than everything. + +ALTER TYPE "account_grant_access" ADD VALUE 'MANAGE'; + +ALTER TABLE "account_grants" ADD COLUMN "scope_json" JSONB; diff --git a/prisma/migrations/0297_managed_profile/migration.sql b/prisma/migrations/0297_managed_profile/migration.sql new file mode 100644 index 000000000..d7d309e46 --- /dev/null +++ b/prisma/migrations/0297_managed_profile/migration.sql @@ -0,0 +1,29 @@ +-- v1.37.0 — a record somebody else runs. +-- +-- A managed profile is an account for a person who cannot run their own +-- record: a child, a parent under care. It has a display name, no e-mail and +-- no password, never signs in, and is administered by whoever holds an active +-- MANAGE grant on it. +-- +-- One column, and the whole of the feature's identity fence hangs off it. An +-- invited adult who granted management of their OWN record must never reach +-- the settings, integrations, notification routing, grant management, AI +-- configuration, export or deletion of that record — those are the surfaces +-- that would let a manager outlive their own revocation. On a managed profile +-- there is no self to reserve them for, so the guardian holds them. The gate +-- is this column and not the grant level, which is what makes the two cases +-- structurally different rather than differently argued. +-- +-- A timestamp rather than a boolean, deliberately. It says since when, which +-- the activity trail needs to be readable across the transition, and clearing +-- it is the emancipation act: the day the person takes the record over, +-- credentials attach (`email` and `password_hash` are already nullable, so +-- nothing here has to change for that) and this goes back to NULL. The grants +-- survive untouched — the handover changes who holds the revoke button, not +-- which rows exist. +-- +-- No index. The column is read by primary key, on a row the request already +-- loaded, and the one enumeration that scans for marked rows (the reminder +-- fan-out) resolves them from the grant side. + +ALTER TABLE "users" ADD COLUMN "managed_profile_at" TIMESTAMPTZ(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78c96f7d7..e53499899 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,28 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + /// v1.37.0 — since when this account is a record somebody else runs. + /// + /// A managed profile is a person who cannot run their own record: it has a + /// display name and no credentials, never signs in, and is administered + /// end to end by whoever holds an active MANAGE grant on it. The marker is + /// what separates that case from an ordinary adult who granted management + /// of their own record — the identity and reach surfaces open for the + /// former and stay shut for the latter, and the gate is this column rather + /// than the grant, so no argument about a grant level can reach them. + /// + /// A timestamp rather than a boolean, for a reason the schema has to keep + /// possible: it says since when, and clearing it is the emancipation act — + /// the day the person takes their own record over, credentials attach and + /// this goes back to NULL, with the grants and the whole activity trail + /// spanning the transition untouched. + /// + /// Consumers land in this release: the guardian resolver in + /// `src/lib/api-handler.ts` reads it on every guardian request, the + /// notification dispatcher fans reminders out to the guardians' channels on + /// it, and the creation flow is what writes it. + managedProfileAt DateTime? @map("managed_profile_at") + // Profile heightCm Float? @map("height_cm") dateOfBirth DateTime? @map("date_of_birth") @@ -5904,6 +5926,14 @@ model ClinicianShareLinkDocument { enum AccountGrantAccess { READ WRITE + /// v1.37.0 — full management of the record's data: everything WRITE reaches, + /// plus the edit, delete and restore verbs beside it, plus the record-wide + /// derived surfaces. Never the identity and reach surfaces — credentials, + /// channels, tokens, integrations, grant management, AI configuration — + /// which stay unreachable at every invited level, because a manager who + /// could mint a credential would survive their own revocation. MANAGE is + /// whole-record by construction: `scopeJson` must be NULL on a MANAGE row. + MANAGE @@map("account_grant_access") } @@ -5970,6 +6000,24 @@ model AccountGrant { access AccountGrantAccess @default(READ) + /// v1.37.0 — which sections of the record this grant opens. + /// + /// NULL means the entire record, which is exactly what every row written + /// before this column existed was consented as — so there is no backfill and + /// nobody is asked to consent again. A non-NULL value is a non-empty array + /// of `ShareDomain` keys (`src/lib/sharing/scope.ts`). + /// + /// Read fail-closed through `normaliseScope` in `src/lib/sharing/grants.ts` + /// and nowhere else: a malformed non-NULL blob resolves to the empty set and + /// refuses every section, the opposite direction from the module preference + /// normaliser, because this column answers an authorization question and + /// that one answers a presentation question. + /// + /// Always NULL on a MANAGE row. Management is whole-record: "they can do + /// anything, but only to part of you" promises a boundary that cannot be + /// kept once edits are in play. + scopeJson Json? @map("scope_json") + /// When the invitation was offered. A domain event, so re-offering an /// invitation on the same pending row moves it; `createdAt` below is row /// bookkeeping and never moves. They agree the day the row is written and diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index bf83b9a2b..df45e02db 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -168,16 +168,25 @@ describe("the cookie-only helpers cannot see the acting account", () => { expect(body).not.toContain("readActingCarrier"); }); - it("substitutes a data-scope user in exactly one function", () => { + it("substitutes a data-scope user in exactly two functions", () => { const src = read("lib/api-handler.ts"); const record = functionBody(src, "requireRecordAuth"); expect(record).toContain("readActingCarrier"); + // v1.37.0 — the guardian resolver is the second function that acts on the + // carrier, and it is a second declaration rather than a level on the + // first: it admits the surfaces of a record NOBODY runs themselves, gated + // on the managed-profile marker rather than on the grant. + const guardian = functionBody(src, "requireGuardianAuth"); + expect(guardian.length).toBeGreaterThan(0); + expect(guardian).toContain("readActingCarrier"); + expect(guardian).toContain("managedProfileAt"); + // `readActingCarrier` is what turns a request into "acting as somebody - // else". Two callers: the bare path, which refuses on it, and the record - // path, which acts on it. A third would be a mode nobody declared. + // else". Three callers: the bare path, which refuses on it, and the two + // record paths, which act on it. A fourth would be a mode nobody declared. const callers = (src.match(/await readActingCarrier\(/g) ?? []).length; - expect(callers).toBe(2); + expect(callers).toBe(3); }); }); diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 4a08f469e..26b85acf9 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -1,13 +1,22 @@ /** - * Structural guards on the delegable and actor-surface allowlists. + * Structural guards on the delegable, guardian and actor-surface allowlists. * * `requireRecordAuth` is a declaration: this route may act on somebody else's - * health record. `requireActorAuth` is the opposite declaration: this route - * serves the caller's own account and keeps working while the caller is acting - * as someone else. Which routes may say either thing is the single most - * security-sensitive list in the product, and it is a judgement — argued in - * `.planning/2026-08-01-delegable-classification.md`, not derived from anything - * a machine can check. + * health record, at a named level and over a named section of it. + * `requireGuardianAuth` is a narrower one: this route administers a record + * belonging to somebody who cannot run their own, and is unreachable on every + * other record at every grant level. `requireActorAuth` is the opposite + * declaration: this route serves the caller's own account and keeps working + * while the caller is acting as someone else. Which routes may say any of the + * three is the single most security-sensitive list in the product, and it is a + * judgement — argued in `.planning/2026-08-01-delegable-classification.md`, not + * derived from anything a machine can check. + * + * v1.37.0 added the section column and the two newer literals. The column is + * the one thing here a guard can do the least about, and the file says so at + * {@link DelegableEntry}: a section transcribed wrongly into the literal agrees + * perfectly with a call site transcribed wrongly the same way. The control is a + * reader; the freeze is only what stops it drifting afterwards. * * That record is an argument on paper. This file is what makes it hold: the set * of route modules that actually reach each resolver has to equal a literal @@ -32,7 +41,11 @@ * depends on. Those counts are the evidence; the empty result is only the * verdict. The delegable list itself was empty when this file was written * and is not any more, which removes the trap from legs (a) and (b) but - * not from the two below them. + * not from the ones below them — and legs (g) and (h) arrive with EMPTY + * literals of their own, so for those the trap is the whole condition of + * landing them early. Each carries the evidence its own matcher works: + * (g) shares `recordNeeds` with the large non-empty read set leg (e) + * measures, and (h) pins the declaration site as a guaranteed positive. * * 2. **AST decides, text only skips.** Membership is decided by parsing the * module and reading its identifiers, so a comment explaining why a route @@ -58,12 +71,41 @@ import { join, sep } from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope"; +import type { ShareScope } from "@/lib/sharing/scope"; + const SRC = join(process.cwd(), "src"); /** The delegable declaration. Frozen by `DELEGABLE_ROUTES`. */ const RECORD_RESOLVER = "requireRecordAuth"; /** The actor-surface declaration. Frozen by `ACTOR_ROUTES`. */ const ACTOR_RESOLVER = "requireActorAuth"; +/** + * v1.37.0 — the guardian declaration. Frozen by `GUARDIAN_ROUTES`. + * + * A third declaration rather than a level on the second, because the routes it + * admits are not health data at all: they are the record's settings, + * integrations, notification routing, grant management, AI configuration, + * export and deletion. Those are reachable on a managed profile, where there + * is no self to reserve them for, and unreachable on an adult's record at + * every grant level. Freezing them as their own list is what makes a route + * moving between the two a diff somebody reviews. + */ +const GUARDIAN_RESOLVER = "requireGuardianAuth"; + +/** + * The set of section names the production enum actually holds, imported rather + * than restated. + * + * A copy of the vocabulary here would let the guard agree with a frozen domain + * that no longer exists — the list would be internally consistent and would + * have stopped describing the tree. Importing it means a renamed or removed + * section fails the membership assertion below instead. + */ +const VALID_SCOPES: ReadonlySet = new Set([ + ...SHARE_DOMAINS, + ENTIRE_RECORD, +]); /** * The helpers that answer a question a delegate must never be able to reach: @@ -233,6 +275,22 @@ interface CallSite { name: string; /** The first argument when it is a string literal; null otherwise. */ firstArg: string | null; + /** + * The second argument when it is a string literal; null otherwise. + * + * v1.37.0 — the domain leg turns on this and nothing else, and it carries + * the same trap the need argument carried: a matcher that returned null for + * every second argument would produce an empty resolved set, which agrees + * with any frozen literal at all. The leg pins a non-zero count of resolved + * domains for that reason, and the matcher's own proof below reads one + * through a direct, an aliased and a namespaced call. + */ + secondArg: string | null; +} + +/** The literal text of an argument, or null when it is not a plain string. */ +function literalArg(node: ts.Node | undefined): string | null { + return node && ts.isStringLiteralLike(node) ? node.text : null; } function callSitesIn(source: string, fileName: string): CallSite[] { @@ -275,10 +333,10 @@ function callSitesIn(source: string, fileName: string): CallSite[] { name = callee.name.text; } if (name !== null) { - const first = node.arguments[0]; calls.push({ name, - firstArg: first && ts.isStringLiteralLike(first) ? first.text : null, + firstArg: literalArg(node.arguments[0]), + secondArg: literalArg(node.arguments[1]), }); } } @@ -363,6 +421,49 @@ function recordNeeds(rel: string): Set { ); } +/** + * The `ShareScope` literals this module passes to the record resolver. + * + * A SET, not a value, because the question the leg asks is "do this module's + * call sites agree with each other and with the frozen entry" — and a module + * whose GET says `medications` while its POST says `labs` is a defect that + * looks like nothing at runtime until a scoped delegate reads the wrong + * section. A set of size other than one is that defect. + */ +function recordDomains(rel: string): Set { + if (!read(rel).includes(RECORD_RESOLVER)) return new Set(); + return new Set( + callSites(rel) + .filter((c) => c.name === RECORD_RESOLVER && c.secondArg !== null) + .map((c) => c.secondArg as string), + ); +} + +/** + * The source text of one top-level exported function, braces included. + * + * Bounded by the parser rather than by an index-of on the next `export`: a + * slice that ran to the end of the file would let a symbol appearing anywhere + * below satisfy an assertion about this function's body, which is the shape of + * "a check that cannot fail". + */ +function functionSource(rel: string, name: string): string { + const text = read(rel); + const parsed = ts.createSourceFile( + rel, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + let found = ""; + parsed.forEachChild((node) => { + if (!ts.isFunctionDeclaration(node) || node.name?.text !== name) return; + found = text.slice(node.getStart(parsed), node.getEnd()); + }); + return found; +} + /** Names of the top-level functions a module exports. */ function exportedFunctionNames(rel: string): string[] { const parsed = ts.createSourceFile( @@ -387,6 +488,28 @@ function exportedFunctionNames(rel: string): string[] { /* The frozen lists */ /* -------------------------------------------------------------------------- */ +/** + * One frozen admission: which section of the record the module touches, and + * why a delegate reaching it cannot extend their own reach. + * + * v1.37.0 added `domain`. The reason lines already said the section in prose — + * "the record's readings", "the medications family" — and the field is what + * makes that sentence machine-checkable against the call site. It is the + * single most review-sensitive column in this file and the one a guard can do + * the least about: a domain transcribed wrongly here agrees with a call site + * transcribed wrongly the same way, and the result is a delegate scoped to one + * section quietly reading another, which looks like nothing from the outside. + * The control is a reader comparing this column against the design's + * clustering table in one sitting; the guard only freezes what that reader + * agreed to. + */ +interface DelegableEntry { + /** The section this module's `requireRecordAuth` calls declare. */ + domain: ShareScope; + /** Why a delegate reaching it cannot extend their own reach. */ + why: string; +} + /** * Route modules permitted to act on a record that may not be the caller's. * @@ -406,117 +529,221 @@ function exportedFunctionNames(rel: string): string[] { * forty-one do. Any write arm not named in the write literal is a diff two * lists have to agree on. */ -const DELEGABLE_ROUTES: Record = { - "app/api/measurements/route.ts": - "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.", - "app/api/measurements/[id]/route.ts": - "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", - "app/api/measurements/series/route.ts": - "A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.", - "app/api/measurements/series-batch/route.ts": - "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.", - "app/api/measurement-reminders/route.ts": - "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", - "app/api/measurement-reminders/[id]/route.ts": - "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", - "app/api/labs/route.ts": - "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result is a delegable write; editing and deleting one are not.", - "app/api/labs/[id]/route.ts": - "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", - "app/api/biomarkers/route.ts": - "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", - "app/api/biomarkers/[id]/route.ts": - "One biomarker of the record, fetch-then-guard against the resolved user.", - "app/api/allergies/route.ts": - "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.", - "app/api/allergies/[id]/route.ts": - "One allergy of the record, fetch-then-guard against the resolved user.", - "app/api/family-history/route.ts": - "The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's. Its POST is not delegable, for the reason its allergy sibling gives plus one of its own — see the route comment.", - "app/api/family-history/[id]/route.ts": - "One family-history entry of the record, fetch-then-guard against the resolved user.", - "app/api/mental-health/assessments/route.ts": - "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", - "app/api/anamnesis/facts/route.ts": - "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", - "app/api/mood-entries/route.ts": - "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", - "app/api/mood-entries/[id]/route.ts": - "One mood entry of the record, fetch-then-guard against the resolved user.", - "app/api/custom-metrics/route.ts": - "The record's own metric definitions and their latest values. Every `where` carries the resolved user id.", - "app/api/custom-metrics/[id]/route.ts": - "One custom metric of the record, scoped by the resolved user id in the query itself.", - "app/api/custom-metrics/[id]/entries/route.ts": - "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query. The READ arm only: the POST left the write literal below in v1.36.x for the reason its allergy and family-history siblings did, and the route comment carries the argument.", - "app/api/personal-records/route.ts": - "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", - "app/api/sleep/night/route.ts": - "The record's hypnogram. Read-only, module-gated on the record, no write of any kind on the path.", - "app/api/sleep/rhythm/route.ts": - "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.", - "app/api/nutrients/route.ts": - "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.", - "app/api/nutrients/daily/route.ts": - "One nutrient's daily series over the record, scoped by the resolved user id.", - "app/api/illness/episodes/route.ts": - "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.", - "app/api/illness/episodes/[id]/route.ts": - "One episode of the record, fetch-then-guard against the resolved user.", - "app/api/illness/episodes/[id]/day-logs/route.ts": - "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", - "app/api/workouts/route.ts": - "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", - "app/api/cycle/cycles/route.ts": - "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", - "app/api/cycle/day-logs/route.ts": - 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', - "app/api/cycle/profile/route.ts": - "The record's cycle profile, supplied by the gate itself. This is the one cycle route that does read the auth-context gender directly — `isCycleEnabled(user.gender, gate.profile)` computes the `resolved` flag it returns — and post-substitution that is the owner's.", - "app/api/cycle/symptoms/custom/route.ts": - "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", - "app/api/medications/route.ts": - "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", - "app/api/medications/[id]/route.ts": - "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", - "app/api/medications/[id]/cadence/route.ts": - "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.", - "app/api/medications/[id]/schedule-revisions/route.ts": - "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", - "app/api/medications/[id]/side-effects/route.ts": - "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm, which is a delegable write and keys its bucket on the ACTOR for exactly that reason.", - "app/api/medications/[id]/inventory/route.ts": - "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", - "app/api/medications/[id]/phase-config/route.ts": - "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", - "app/api/medications/[id]/intake/route.ts": - "One medication's intake history, behind the same ownership guard. Marking a dose is a delegable write, and the one that notifies the owner rather than only filing an audit row.", - "app/api/medications/intake/route.ts": - "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to, and the canonical slot write on the POST arm is delegable.", - "app/api/medications/compliance/route.ts": - "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", +const DELEGABLE_ROUTES: Record = { + "app/api/measurements/route.ts": { + domain: "measurements", + why: "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.", + }, + "app/api/measurements/[id]/route.ts": { + domain: "measurements", + why: "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", + }, + "app/api/measurements/series/route.ts": { + domain: "measurements", + why: "A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.", + }, + "app/api/measurements/series-batch/route.ts": { + domain: "measurements", + why: "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.", + }, + "app/api/measurement-reminders/route.ts": { + domain: "measurements", + why: "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", + }, + "app/api/measurement-reminders/[id]/route.ts": { + domain: "measurements", + why: "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", + }, + "app/api/labs/route.ts": { + domain: "labs", + why: "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result is a delegable write; editing and deleting one are not.", + }, + "app/api/labs/[id]/route.ts": { + domain: "labs", + why: "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", + }, + "app/api/biomarkers/route.ts": { + domain: "labs", + why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", + }, + "app/api/biomarkers/[id]/route.ts": { + domain: "labs", + why: "One biomarker of the record, fetch-then-guard against the resolved user.", + }, + "app/api/allergies/route.ts": { + domain: "profile", + why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.", + }, + "app/api/allergies/[id]/route.ts": { + domain: "profile", + why: "One allergy of the record, fetch-then-guard against the resolved user.", + }, + "app/api/family-history/route.ts": { + domain: "profile", + why: "The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's. Its POST is not delegable, for the reason its allergy sibling gives plus one of its own — see the route comment.", + }, + "app/api/family-history/[id]/route.ts": { + domain: "profile", + why: "One family-history entry of the record, fetch-then-guard against the resolved user.", + }, + "app/api/mental-health/assessments/route.ts": { + domain: "mind", + why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", + }, + "app/api/anamnesis/facts/route.ts": { + domain: "profile", + why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", + }, + "app/api/mood-entries/route.ts": { + domain: "mind", + why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", + }, + "app/api/mood-entries/[id]/route.ts": { + domain: "mind", + why: "One mood entry of the record, fetch-then-guard against the resolved user.", + }, + "app/api/custom-metrics/route.ts": { + domain: "measurements", + why: "The record's own metric definitions and their latest values. Every `where` carries the resolved user id.", + }, + "app/api/custom-metrics/[id]/route.ts": { + domain: "measurements", + why: "One custom metric of the record, scoped by the resolved user id in the query itself.", + }, + "app/api/custom-metrics/[id]/entries/route.ts": { + domain: "measurements", + why: "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query. The READ arm only: the POST left the write literal below in v1.36.x for the reason its allergy and family-history siblings did, and the route comment carries the argument.", + }, + "app/api/personal-records/route.ts": { + domain: "measurements", + why: "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", + }, + "app/api/sleep/night/route.ts": { + domain: "measurements", + why: "The record's hypnogram. Read-only, module-gated on the record, no write of any kind on the path.", + }, + "app/api/sleep/rhythm/route.ts": { + domain: "measurements", + why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.", + }, + "app/api/nutrients/route.ts": { + domain: "measurements", + why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.", + }, + "app/api/nutrients/daily/route.ts": { + domain: "measurements", + why: "One nutrient's daily series over the record, scoped by the resolved user id.", + }, + "app/api/illness/episodes/route.ts": { + domain: "illness", + why: "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.", + }, + "app/api/illness/episodes/[id]/route.ts": { + domain: "illness", + why: "One episode of the record, fetch-then-guard against the resolved user.", + }, + "app/api/illness/episodes/[id]/day-logs/route.ts": { + domain: "illness", + why: "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", + }, + "app/api/workouts/route.ts": { + domain: "measurements", + why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", + }, + "app/api/cycle/cycles/route.ts": { + domain: "cycle", + why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", + }, + "app/api/cycle/day-logs/route.ts": { + domain: "cycle", + why: 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', + }, + "app/api/cycle/profile/route.ts": { + domain: "cycle", + why: "The record's cycle profile, supplied by the gate itself. This is the one cycle route that does read the auth-context gender directly — `isCycleEnabled(user.gender, gate.profile)` computes the `resolved` flag it returns — and post-substitution that is the owner's.", + }, + "app/api/cycle/symptoms/custom/route.ts": { + domain: "cycle", + why: "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", + }, + "app/api/medications/route.ts": { + domain: "medications", + why: "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", + }, + "app/api/medications/[id]/route.ts": { + domain: "medications", + why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", + }, + "app/api/medications/[id]/cadence/route.ts": { + domain: "medications", + why: "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.", + }, + "app/api/medications/[id]/schedule-revisions/route.ts": { + domain: "medications", + why: "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", + }, + "app/api/medications/[id]/side-effects/route.ts": { + domain: "medications", + why: "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm, which is a delegable write and keys its bucket on the ACTOR for exactly that reason.", + }, + "app/api/medications/[id]/inventory/route.ts": { + domain: "medications", + why: "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + }, + "app/api/medications/[id]/phase-config/route.ts": { + domain: "medications", + why: "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", + }, + "app/api/medications/[id]/intake/route.ts": { + domain: "medications", + why: "One medication's intake history, behind the same ownership guard. Marking a dose is a delegable write, and the one that notifies the owner rather than only filing an audit row.", + }, + "app/api/medications/intake/route.ts": { + domain: "medications", + why: "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to, and the canonical slot write on the POST arm is delegable.", + }, + "app/api/medications/compliance/route.ts": { + domain: "medications", + why: "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", + }, // The front door. Eight reads admitted together because `/` is the first // page a delegate lands on and every one of them was refusing there, which // made the entry point of the feature the worst surface in it. Six are // aggregates or record state; two are presentation blobs whose write arms // stay bare, and those two say so in their own line below. - "app/api/dashboard/snapshot/route.ts": - "The record's tile strip, and the widest aggregate in the product. Admitted on the whole-record grant: every input is a module the delegate may read directly, the briefing prose is lifted read-only off the owner's row rather than generated, and no credential or integration endpoint is in the payload. First route to re-examine if per-module scope ever lands.", - "app/api/daily/digest/route.ts": - "The record's Today hero. Same family and same argument as the snapshot, assembled from already-cached values with no provider on the path; the `insights` module gate now resolves against the RECORD, so a delegate gets the hero only where the owner switched it on.", - "app/api/gamification/achievements/route.ts": - "The record's badges, every one derived from the record's own history. Read-only in fact as well as in declaration since v1.35.3 moved the unlock INSERT onto the sweep job. Spans every module carrying a badge category, so it joins the snapshot and the digest in the per-module-scope re-examination.", - "app/api/insights/coach/nudge-status/route.ts": - "Whether the RECORD's Coach thread holds something unopened — a timestamp, a boolean and a conversation id. Not in tension with the Coach chat staying refused: chat spends the owner's AI budget and writes into their conversation, and this reads neither.", - "app/api/coach/reminders/route.ts": - "The record's reminder ledger. A 'remind me about X' note is a statement about the owner's own health, stored and encrypted on their row — the same shape as a mood note. The POST beside it keeps `requireAuth()`: writing into somebody's Coach memory puts a delegate's words in the voice the Coach reads back to the owner.", - "app/api/settings/reminder-thresholds/route.ts": - "The record's low-stock runway and reorder lead, which decide whether the OWNER's medication cards read 'low stock'. Projects exactly two integers out of `notificationPrefs`; the channels and endpoints in that object are unreachable from here and the route that serves it whole stays refused. No write arm exists.", - "app/api/dashboard/widgets/route.ts": - "The record's dashboard layout, read only. Settled by a fact rather than a preference: the snapshot already carries this layout and the client seeds the same cache cell from it, so an actor answer would put two people's arrangements in one key. The PUT and DELETE stay bare — a delegate adds to a record, never redecorates it.", - "app/api/medications/layout/route.ts": - "The record's medication-list presentation, read only. The stored `order` is a list of the OWNER's medication ids and unknown ids are dropped at apply time, so the caller's own order resolves to nothing against the owner's cabinet. The PUT and DELETE stay bare, and this is the refusal a delegate can actually walk into, since `/medications` is a shared destination.", + "app/api/dashboard/snapshot/route.ts": { + domain: "record", + why: "The record's tile strip, and the widest aggregate in the product. Admitted on the whole-record grant: every input is a module the delegate may read directly, the briefing prose is lifted read-only off the owner's row rather than generated, and no credential or integration endpoint is in the payload. First route to re-examine if per-module scope ever lands.", + }, + "app/api/daily/digest/route.ts": { + domain: "record", + why: "The record's Today hero. Same family and same argument as the snapshot, assembled from already-cached values with no provider on the path; the `insights` module gate now resolves against the RECORD, so a delegate gets the hero only where the owner switched it on.", + }, + "app/api/gamification/achievements/route.ts": { + domain: "record", + why: "The record's badges, every one derived from the record's own history. Read-only in fact as well as in declaration since v1.35.3 moved the unlock INSERT onto the sweep job. Spans every module carrying a badge category, so it joins the snapshot and the digest in the per-module-scope re-examination.", + }, + "app/api/insights/coach/nudge-status/route.ts": { + domain: "record", + why: "Whether the RECORD's Coach thread holds something unopened — a timestamp, a boolean and a conversation id. Not in tension with the Coach chat staying refused: chat spends the owner's AI budget and writes into their conversation, and this reads neither.", + }, + "app/api/coach/reminders/route.ts": { + domain: "record", + why: "The record's reminder ledger. A 'remind me about X' note is a statement about the owner's own health, stored and encrypted on their row — the same shape as a mood note. The POST beside it keeps `requireAuth()`: writing into somebody's Coach memory puts a delegate's words in the voice the Coach reads back to the owner.", + }, + "app/api/settings/reminder-thresholds/route.ts": { + domain: "medications", + why: "The record's low-stock runway and reorder lead, which decide whether the OWNER's medication cards read 'low stock'. Projects exactly two integers out of `notificationPrefs`; the channels and endpoints in that object are unreachable from here and the route that serves it whole stays refused. No write arm exists.", + }, + "app/api/dashboard/widgets/route.ts": { + domain: "record", + why: "The record's dashboard layout, read only. Settled by a fact rather than a preference: the snapshot already carries this layout and the client seeds the same cache cell from it, so an actor answer would put two people's arrangements in one key. The PUT and DELETE stay bare — a delegate adds to a record, never redecorates it.", + }, + "app/api/medications/layout/route.ts": { + domain: "medications", + why: "The record's medication-list presentation, read only. The stored `order` is a list of the OWNER's medication ids and unknown ids are dropped at apply time, so the caller's own order resolves to nothing against the owner's cabinet. The PUT and DELETE stay bare, and this is the refusal a delegate can actually walk into, since `/medications` is a shared destination.", + }, // The document vault. Five reads admitted together for the reason the front // door's eight were: `/documents` carries `sharedRecord: true`, so the shell @@ -524,16 +751,26 @@ const DELEGABLE_ROUTES: Record = { // v1.36.1 describes as fixed on `/`. Uploading, retyping, deleting, linking, // indexing and every AI verb stay bare; the split is per-arm, because the // resolver escalates a non-safe method to `"write"` on its own. - "app/api/documents/inbound/route.ts": - "The record's document list. The GET never selects the encrypted blob column and scopes every predicate to the resolved user id; the POST upload beside it is not delegable and keeps `requireAuth()`.", - "app/api/documents/inbound/[id]/route.ts": - "One document of the record with its staged facts and condition links, fetched under the resolved user id before anything is serialised. The PATCH and DELETE are edits and stay with the owner.", - "app/api/documents/inbound/[id]/thumbnail/route.ts": - "One document's thumbnail — the tile that makes the admitted list browsable. Owner-scoped through the 1:1 relation on the resolved document; the read-quota bucket keys on the ACTOR because decrypting costs something.", - "app/api/documents/inbound/[id]/original/route.ts": - "The document itself, which is the read the rest of the vault exists to reach: a caregiver needs the discharge letter, not its filing card. Same owner-scoped lookup, and the same actor-keyed bucket as the thumbnail, tighter because it decrypts the whole file.", - "app/api/documents/inbound/usage/route.ts": - "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", + "app/api/documents/inbound/route.ts": { + domain: "documents", + why: "The record's document list. The GET never selects the encrypted blob column and scopes every predicate to the resolved user id; the POST upload beside it is not delegable and keeps `requireAuth()`.", + }, + "app/api/documents/inbound/[id]/route.ts": { + domain: "documents", + why: "One document of the record with its staged facts and condition links, fetched under the resolved user id before anything is serialised. The PATCH and DELETE are edits and stay with the owner.", + }, + "app/api/documents/inbound/[id]/thumbnail/route.ts": { + domain: "documents", + why: "One document's thumbnail — the tile that makes the admitted list browsable. Owner-scoped through the 1:1 relation on the resolved document; the read-quota bucket keys on the ACTOR because decrypting costs something.", + }, + "app/api/documents/inbound/[id]/original/route.ts": { + domain: "documents", + why: "The document itself, which is the read the rest of the vault exists to reach: a caregiver needs the discharge letter, not its filing card. Same owner-scoped lookup, and the same actor-keyed bucket as the thumbnail, tighter because it decrypts the whole file.", + }, + "app/api/documents/inbound/usage/route.ts": { + domain: "documents", + why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", + }, }; /** @@ -599,6 +836,47 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Marking a dose, per-medication form. Same family, same ownership guard against the record, same notification to the owner; this arm has no snooze, so the state is always one of the two markings.", }; +/** + * v1.37.0 — route modules permitted to MANAGE a record that is not the + * caller's: the edit, delete and restore verbs beside the admitted creates, + * and the record-wide derived reads. + * + * **Empty on purpose, and the emptiness is not the finished state.** The + * vocabulary, the resolver and the sweep land first; the classification pass + * that decides which verbs may say `"manage"` is its own piece of work, and + * the routes are flipped against its appendix afterwards. Landing the leg + * before the literal is deliberate: an empty frozen list that the tree is + * measured against is the thing that makes the first admission arrive as a + * diff here rather than as a call site somebody added. + * + * Which is exactly the shape this file's own header warns about — a leg whose + * result set starts empty reports success whether or not its matcher works. So + * the leg below pins its own non-zero evidence: the number of route modules + * scanned, and the number of resolved domain literals the SAME matcher found + * across the tree. A blinded second-argument matcher drops the second count to + * zero and fails, rather than agreeing that nothing manages anything. + */ +const DELEGABLE_MANAGE_ROUTES: Record = {}; + +/** + * v1.37.0 — route modules permitted to administer a MANAGED PROFILE: a record + * belonging to somebody who cannot run their own. + * + * The other empty literal, and the more dangerous one. Its members are the + * identity and reach surfaces — settings, integrations, notification routing, + * grant management, AI configuration and consent, export, deletion — which are + * refused on an adult's record at every grant level and reachable here only + * because a managed profile has no self to reserve them for. `requireGuardianAuth` + * gates them on the profile marker and not on the grant, so no future argument + * about what MANAGE means can reach them. + * + * Same reasoning about the emptiness, same non-zero evidence in the leg, plus + * one anchor the other lists do not need: the resolver has to be exported from + * the auth layer, or "no module reaches it" would be true because the symbol + * had been renamed rather than because no module reaches it. + */ +const GUARDIAN_ROUTES: Record = {}; + /** * Route modules that deliberately resolve the ACTOR and keep working under a * switch, because they serve the caller's own account and nothing of the @@ -626,9 +904,14 @@ const ACTOR_ROUTES: Record = { }; /** - * Entries across all three lists. Pinned so that the reason-loop below cannot + * Entries across all five lists. Pinned so that the reason-loop below cannot * stay a formality by accident: every addition has to be counted here as well * as listed above, which is one more place a careless admission has to pass. + * + * v1.37.0 — unchanged at 69, because the two new literals land empty. It is + * one number rather than one per list on purpose: filling either of them + * collides here by construction, so two people filling them at once find out + * from the merge rather than from production. */ const FROZEN_ENTRY_COUNT = 69; @@ -667,7 +950,7 @@ describe("the matchers see what they look for", () => { import { apiSuccess } from "@/lib/api-response"; export const GET = apiHandler(async () => { - const { user, actor } = await requireRecordAuth("read"); + const { user, actor } = await requireRecordAuth("read", "measurements"); return apiSuccess({ owner: user.id, actor: actor.id }); }); `; @@ -676,7 +959,7 @@ describe("the matchers see what they look for", () => { import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; export const GET = apiHandler(async () => { - const { user } = await resolveRecord("read"); + const { user } = await resolveRecord("read", "labs"); return apiSuccess({ id: user.id }); }); `; @@ -752,13 +1035,14 @@ describe("the matchers see what they look for", () => { expect([...callSitesIn(DELEGABLE_MODULE, "d.ts")]).toContainEqual({ name: RECORD_RESOLVER, firstArg: "read", + secondArg: "measurements", }); const WRITE_MODULE = ` import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; export const POST = apiHandler(async () => { - const { user } = await resolveRecord("write"); + const { user } = await resolveRecord("write", "labs"); await auditLog("thing.create", { userId: user.id }); return apiSuccess({ id: user.id }); }); @@ -770,6 +1054,7 @@ describe("the matchers see what they look for", () => { expect(aliased).toContainEqual({ name: RECORD_RESOLVER, firstArg: "write", + secondArg: "labs", }); expect(aliased.some((c) => c.name === "auditLog")).toBe(true); @@ -777,16 +1062,77 @@ describe("the matchers see what they look for", () => { import * as auth from "@/lib/api-handler"; export const POST = auth.apiHandler(async () => { - const { user } = await auth.requireRecordAuth("write"); + const { user } = await auth.requireRecordAuth("write", "cycle"); return apiSuccess({ id: user.id }); }); `; expect(callSitesIn(NAMESPACED_WRITE, "nw.ts")).toContainEqual({ name: RECORD_RESOLVER, firstArg: "write", + secondArg: "cycle", }); }); + it("reads the section argument, and reads it as its own position", () => { + // v1.37.0 — leg (f) turns on the SECOND argument, and the failure it has + // to survive is a matcher that reports null for every one of them: the + // per-module comparison would then be `new Set()` against a frozen domain + // and would fail loudly, but only because the leg also pins a non-zero + // total. Both halves are measured here. + const MODULE = ` + import { requireRecordAuth } from "@/lib/api-handler"; + + export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "documents"); + return apiSuccess({ id: user.id }); + }); + `; + const sites = callSitesIn(MODULE, "s.ts"); + expect(sites.length).toBeGreaterThan(0); + expect(sites).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "read", + secondArg: "documents", + }); + + // Positional, not "any string in the call". A one-argument call must + // report null rather than sliding the first argument into the second slot, + // or every pre-v1.37.0 call site would appear to declare a section named + // `read`. + const UNCLASSIFIED = ` + import { requireRecordAuth } from "@/lib/api-handler"; + + export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read"); + return apiSuccess({ id: user.id }); + }); + `; + expect(callSitesIn(UNCLASSIFIED, "u.ts")).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "read", + secondArg: null, + }); + + // And a section named in prose or in a constant is not a declaration. A + // grep-shaped matcher reads `labs` out of this file twice. + const NOT_A_DECLARATION = ` + /** Delegable under measurements. Nothing here touches labs. */ + import { requireRecordAuth } from "@/lib/api-handler"; + + const SECTION = "labs"; + + export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "measurements"); + return apiSuccess({ id: user.id, section: SECTION }); + }); + `; + const declared = callSitesIn(NOT_A_DECLARATION, "nd.ts") + .filter((c) => c.name === RECORD_RESOLVER) + .map((c) => c.secondArg); + expect(declared).toEqual(["measurements"]); + expect(NOT_A_DECLARATION.includes('"labs"')).toBe(true); + }); + it("does not read a need out of prose or an unrelated string", () => { // The word "write" appears twice as raw text here and neither occurrence // is an argument to the resolver. A grep-shaped matcher enrols this file. @@ -797,7 +1143,7 @@ describe("the matchers see what they look for", () => { const MODE = "write"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "mind"); return apiSuccess({ id: user.id, mode: MODE }); }); `; @@ -805,11 +1151,12 @@ describe("the matchers see what they look for", () => { // Non-zero: the parse produced call sites, so the negative below is a // measurement rather than an empty walk. expect(sites.length).toBeGreaterThan(0); - expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "read" }); - expect(sites).not.toContainEqual({ + expect(sites).toContainEqual({ name: RECORD_RESOLVER, - firstArg: "write", + firstArg: "read", + secondArg: "mind", }); + expect(sites.filter((c) => c.firstArg === "write")).toEqual([]); expect(NOT_A_WRITE.includes('"write"')).toBe(true); }); @@ -822,12 +1169,16 @@ describe("the matchers see what they look for", () => { import { requireRecordAuth } from "@/lib/api-handler"; export const POST = apiHandler(async () => { - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "illness"); return apiSuccess({ id: user.id, helper: auditLog.name }); }); `; const sites = callSitesIn(IMPORTED_ONLY, "io.ts"); - expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "write" }); + expect(sites).toContainEqual({ + name: RECORD_RESOLVER, + firstArg: "write", + secondArg: "illness", + }); expect(sites.some((c) => c.name === "auditLog")).toBe(false); // …while the identifier matcher, which is the one this would have fooled, // says yes. @@ -885,6 +1236,28 @@ describe("the matchers see what they look for", () => { expect(ids.has(RECORD_RESOLVER)).toBe(true); expect(COOKIE_ONLY_HELPERS.some((h) => ids.has(h))).toBe(true); }); + + it("the two-resolvers rule fires on a module that names both", () => { + // v1.37.0 — leg (c)'s second half finds nothing on today's tree because + // the guardian list is empty. This is the shape it exists to catch: one + // module with two answers to "whose record is this `user`". + const OFFENDER = ` + import { requireRecordAuth, requireGuardianAuth } from "@/lib/api-handler"; + + export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "measurements"); + return apiSuccess({ id: user.id }); + }); + + export const PATCH = apiHandler(async () => { + const { user } = await requireGuardianAuth(); + return apiSuccess({ id: user.id }); + }); + `; + const ids = identifiersIn(OFFENDER, "two.ts"); + expect(ids.has(RECORD_RESOLVER)).toBe(true); + expect(ids.has(GUARDIAN_RESOLVER)).toBe(true); + }); }); /* -------------------------------------------------------------------------- */ @@ -892,16 +1265,21 @@ describe("the matchers see what they look for", () => { /* -------------------------------------------------------------------------- */ describe("the resolvers exist and live in one place", () => { - it("both are exported from the auth layer", () => { - // If either symbol were renamed, every leg below would find nothing and - // agree with its empty allowlist. This is the assertion that stops that. + it("all three are exported from the auth layer", () => { + // If any symbol were renamed, every leg below would find nothing and + // agree with its empty allowlist. This is the assertion that stops that, + // and for `requireGuardianAuth` it is currently the ONLY thing standing + // between "no route administers a managed profile" and "the guard is + // looking for a function that does not exist": its frozen list is empty, + // so its leg's negative result proves nothing on its own. const exported = exportedFunctionNames("lib/api-handler.ts"); expect(exported.length).toBeGreaterThan(0); expect(exported).toContain(RECORD_RESOLVER); expect(exported).toContain(ACTOR_RESOLVER); + expect(exported).toContain(GUARDIAN_RESOLVER); }); - it("nothing outside the frozen route lists reaches either resolver", () => { + it("nothing outside the frozen route lists reaches any resolver", () => { // A shared helper calling `requireRecordAuth` would make every route that // imports it delegable without any route file saying so. The route-level // legs below cannot see that; this one can. @@ -910,15 +1288,18 @@ describe("the resolvers exist and live in one place", () => { const naming = files.filter( (rel) => - namesSymbol(rel, RECORD_RESOLVER) || namesSymbol(rel, ACTOR_RESOLVER), + namesSymbol(rel, RECORD_RESOLVER) || + namesSymbol(rel, ACTOR_RESOLVER) || + namesSymbol(rel, GUARDIAN_RESOLVER), ); expect(naming.length).toBeGreaterThan(0); expect(naming).toEqual( [ - // Declares both. Not a caller. + // Declares all three. Not a caller. "lib/api-handler.ts", ...Object.keys(DELEGABLE_ROUTES), + ...Object.keys(GUARDIAN_ROUTES), ...Object.keys(ACTOR_ROUTES), ].sort(), ); @@ -986,9 +1367,15 @@ describe("(b) the actor-surface route set is frozen", () => { describe("every frozen entry carries a reason", () => { it("names why the route may say what it says", () => { - const entries = [ - ...Object.entries(DELEGABLE_ROUTES), + const entries: [string, string][] = [ + ...Object.entries(DELEGABLE_ROUTES).map( + ([rel, entry]): [string, string] => [rel, entry.why], + ), + ...Object.entries(DELEGABLE_MANAGE_ROUTES).map( + ([rel, entry]): [string, string] => [rel, entry.why], + ), ...Object.entries(DELEGABLE_WRITE_ROUTES), + ...Object.entries(GUARDIAN_ROUTES), ...Object.entries(ACTOR_ROUTES), ]; @@ -1022,7 +1409,12 @@ describe("(c) a record resolver never shares a module with a role or step-up che } const offenders = scanned.filter((rel) => { - if (!namesSymbol(rel, RECORD_RESOLVER)) return false; + if ( + !namesSymbol(rel, RECORD_RESOLVER) && + !namesSymbol(rel, GUARDIAN_RESOLVER) + ) { + return false; + } const ids = identifiers(rel); return COOKIE_ONLY_HELPERS.some((helper) => ids.has(helper)); }); @@ -1030,6 +1422,31 @@ describe("(c) a record resolver never shares a module with a role or step-up che expect(offenders).toEqual([]); }); + it("no module names both record resolvers", () => { + // v1.37.0 — the co-occurrence hazard this leg exists for, one ring out. + // `requireRecordAuth` resolves a record a delegate may hold at some level; + // `requireGuardianAuth` resolves one only a guardian may touch at all. A + // module holding both has two answers to "who is this `user`", and the + // next person to edit an arm has no way to tell which one they are in. + // The route moves list, or it splits into two routes. + const scanned = routeModules(); + expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); + + // Non-zero proof, and the one that carries this leg: the guardian list is + // empty today, so the offender set below is empty whether or not either + // matcher works. The record resolver IS reached, by many modules, by the + // same predicate — if that count went to zero the matcher would be broken + // and this leg would be agreeing with nothing. + expect(modulesNaming(scanned, RECORD_RESOLVER).length).toBeGreaterThan(0); + + const offenders = scanned.filter( + (rel) => + namesSymbol(rel, RECORD_RESOLVER) && + namesSymbol(rel, GUARDIAN_RESOLVER), + ); + expect(offenders).toEqual([]); + }); + it("the declaration site is out of scope by construction", () => { // `lib/api-handler.ts` declares the record resolver AND all three cookie- // only helpers, so a scan of every source file would need it exempted, and @@ -1157,6 +1574,156 @@ describe("(e) the delegable write set is frozen", () => { }); }); +/* -------------------------------------------------------------------------- */ +/* (f) the declared domain */ +/* -------------------------------------------------------------------------- */ + +describe("(f) every delegable module declares the frozen section", () => { + it("agrees with itself and with the frozen entry", () => { + const scanned = routeModules(); + expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); + + // The non-zero half, and the one that makes this leg mean anything. The + // comparison below is per-module and would pass vacuously for a module the + // matcher read nothing out of: `new Set()` versus a frozen domain fails, + // but a matcher blinded across the board would fail every module rather + // than none, so the honest evidence is the total. A second-argument + // matcher that returned null everywhere drops this to zero. + const resolved = scanned.reduce( + (total, rel) => total + recordDomains(rel).size, + 0, + ); + expect(resolved).toBeGreaterThan(50); + + for (const [rel, entry] of Object.entries(DELEGABLE_ROUTES)) { + const declared = recordDomains(rel); + // One section per module. A module whose arms disagree is the defect + // that looks like nothing at runtime: a scoped delegate reads the wrong + // section through the arm nobody checked. + expect([...declared], `${rel} declares more than one section`).toEqual([ + entry.domain, + ]); + } + }); + + it("every frozen section is a member of the production vocabulary", () => { + // Imported, not restated. A section renamed or removed in + // `src/lib/sharing/scope.ts` fails here rather than leaving this file + // internally consistent and no longer about the tree. + expect(VALID_SCOPES.size).toBe(SHARE_DOMAINS.length + 1); + + for (const [rel, entry] of Object.entries(DELEGABLE_ROUTES)) { + expect(VALID_SCOPES.has(entry.domain), `${rel}: ${entry.domain}`).toBe( + true, + ); + } + for (const [rel, entry] of Object.entries(DELEGABLE_MANAGE_ROUTES)) { + expect(VALID_SCOPES.has(entry.domain), `${rel}: ${entry.domain}`).toBe( + true, + ); + } + }); +}); + +/* -------------------------------------------------------------------------- */ +/* (g) the MANAGE set */ +/* -------------------------------------------------------------------------- */ + +describe("(g) the MANAGE route set is frozen", () => { + it("equals the frozen manage list", () => { + const scanned = routeModules(); + expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); + + // The literal is empty today, so the assertion below is `[] === []` and + // proves nothing by itself. What carries it is the matcher that produced + // the left-hand side being the SAME one that produced a large non-empty + // read set two legs up — `recordNeeds` decides both, so a blinded matcher + // fails leg (e) loudly before this one can agree with itself. + const readNeeds = scanned.filter((rel) => recordNeeds(rel).has("read")); + expect(readNeeds.length).toBeGreaterThan(20); + + const managing = scanned.filter((rel) => recordNeeds(rel).has("manage")); + expect(managing).toEqual(Object.keys(DELEGABLE_MANAGE_ROUTES).sort()); + }); + + it("is a subset of the delegable set", () => { + // A module cannot manage a record it may not read. The read list is where + // each file's argument lives, so a manage entry with no read entry would + // be an admission with no recorded reason. + for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) { + expect(DELEGABLE_ROUTES, rel).toHaveProperty([rel]); + } + }); + + it("every manage module files its rows through auditLog", () => { + // Inherited from the write leg's condition of admission, and it matters + // more here: a MANAGE verb rewrites or removes record data, and `auditLog` + // is the only writer that stamps `actorUserId`. A destruction filed + // without an actor is indistinguishable from the owner having done it. + for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) { + expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( + true, + ); + } + }); + + it("no manage module reaches the audit table without the helper", () => { + for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) { + expect( + directAuditWritesIn(read(rel), rel), + `${rel} writes an audit row without the helper`, + ).toEqual([]); + } + }); +}); + +/* -------------------------------------------------------------------------- */ +/* (h) the guardian set */ +/* -------------------------------------------------------------------------- */ + +describe("(h) the guardian route set is frozen", () => { + it("equals the frozen guardian list", () => { + const scanned = routeModules(); + expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); + + // Positive control for this leg's own matcher, the same one leg (a) uses + // and for the same reason twice over: the literal is empty, so "no route + // names the resolver" and "the matcher names nothing" are one sentence + // until something the matcher CAN find is named. `lib/api-handler.ts` + // declares the symbol and is the guaranteed positive. + expect(modulesNaming(["lib/api-handler.ts"], GUARDIAN_RESOLVER)).toEqual([ + "lib/api-handler.ts", + ]); + + expect(modulesNaming(scanned, GUARDIAN_RESOLVER)).toEqual( + Object.keys(GUARDIAN_ROUTES).sort(), + ); + }); + + it("the marker is what the resolver gates on", () => { + // The anchor, in the shape leg (d) anchors the Bearer surfaces: the + // guardian list means "these routes are gated on the managed-profile + // marker", and that sentence is only true while the resolver reads the + // marker. A resolver that stopped reading it would leave every leg here + // passing and the identity fence gone — an invited adult's MANAGE grant + // would reach the settings of a record its owner still runs. + const guardian = functionSource("lib/api-handler.ts", GUARDIAN_RESOLVER); + // Non-zero proof, twice: the slice found a function, and it is the one it + // claims to be. A renamed resolver must fail here rather than assert + // against an empty string. + expect(guardian.length).toBeGreaterThan(0); + expect(guardian).toContain(GUARDIAN_RESOLVER); + expect(guardian).toContain("managedProfileAt"); + + // …and the record resolver must NOT read it. The two resolvers differ in + // exactly this fact, so a marker check that drifted into the delegable + // path would make the fence meaningless while every list still agreed. + const record = functionSource("lib/api-handler.ts", RECORD_RESOLVER); + expect(record.length).toBeGreaterThan(0); + expect(record).not.toContain("managedProfileAt"); + }); +}); + /* -------------------------------------------------------------------------- */ /* The counter-test: quiet on the innocent tree */ /* -------------------------------------------------------------------------- */ @@ -1170,13 +1737,17 @@ describe("the guard is quiet on the tree it was written against", () => { // makes "no findings" a measurement rather than a hope: 344 modules read, // parsed where the substring appears, and none of them names a resolver. const bare = modulesNaming(scanned, "requireAuth").filter( - (rel) => !(rel in DELEGABLE_ROUTES) && !(rel in ACTOR_ROUTES), + (rel) => + !(rel in DELEGABLE_ROUTES) && + !(rel in ACTOR_ROUTES) && + !(rel in GUARDIAN_ROUTES), ); expect(bare.length).toBeGreaterThan(BARE_REQUIRE_AUTH_FLOOR); for (const rel of bare) { expect(namesSymbol(rel, RECORD_RESOLVER), rel).toBe(false); expect(namesSymbol(rel, ACTOR_RESOLVER), rel).toBe(false); + expect(namesSymbol(rel, GUARDIAN_RESOLVER), rel).toBe(false); } }); }); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 8e92d7c15..6fb60e03c 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -142,6 +142,13 @@ export const POST = apiHandler(async (request: NextRequest) => { grantorId: user.id, granteeId: invitee.id, access, + // The entire record, which is what this endpoint has always offered and + // what every stored grant means. Named rather than defaulted, because + // the domain module takes the scope as a required argument for the same + // reason it takes the level as one: a scope nobody chose is not a scope. + // The consent surface that lets an owner narrow it lands beside this + // line, in this release. + scope: null, expiresAt: expiresAt ? new Date(expiresAt) : null, }); diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index 621da46b0..6276c3dba 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "profile"); const { id } = await params; const row = await prisma.allergy.findUnique({ where: { id } }); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 8225b7e0d..c51e799e7 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -29,7 +29,7 @@ import { import { toAllergyDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = allergyListQuerySchema.safeParse({ diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 228a7d860..55c75ad0d 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -19,7 +19,7 @@ import { import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "profile"); const payload = await readHealthProfileFacts(user.id); annotate({ action: { name: "anamnesis.facts.get" }, diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 06aa4e5a5..37aa32673 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -64,7 +64,7 @@ function serialiseBiomarker(row: { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "labs"); const { id } = await params; const row = await prisma.biomarker.findFirst({ where: { id } }); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 3880e9c1c..b0d502964 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -71,7 +71,7 @@ function serialiseBiomarker(row: { } export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "labs"); // Visible markers first, then hidden — the manager renders them in two // sections and the lab-entry pickers drop the hidden tail client-side. @@ -95,7 +95,7 @@ async function postBiomarker(request: NextRequest) { // duplicate pre-check and the `@@unique([userId, name])` backstop both scope // to the same resolved id, so a delegate adding a marker the owner already // tracks gets the ordinary 409 rather than a second copy. - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "labs"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts index de28f94f1..1291292a6 100644 --- a/src/app/api/coach/reminders/route.ts +++ b/src/app/api/coach/reminders/route.ts @@ -79,7 +79,7 @@ export const GET = apiHandler(async (req: Request) => { // // The module gate below resolves against the record, so the surface exists // for a delegate only where the OWNER switched the Coach on. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); const gate = await requireModuleEnabled(user.id, "coach"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 7848d9c84..2defc447f 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 7925b7d8b..de09d2910 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -38,7 +38,7 @@ function effectiveBound( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; const row = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 0d4a71e29..e3c0d948d 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics"; */ export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const rows = await prisma.customMetric.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index e5619c70a..6e1c65fb3 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24; const REGULAR_MAD_THRESHOLD = 2; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 421c6b447..fb9a4a480 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -48,7 +48,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); * row id. Gated + owner-scoped + `deletedAt:null`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts index 8b6fbb04b..f0eb5813e 100644 --- a/src/app/api/cycle/profile/route.ts +++ b/src/app/api/cycle/profile/route.ts @@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index b8b2e22d6..7c66a5c8a 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -41,7 +41,7 @@ import { export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/daily/digest/route.ts b/src/app/api/daily/digest/route.ts index befd73edf..78b09aebe 100644 --- a/src/app/api/daily/digest/route.ts +++ b/src/app/api/daily/digest/route.ts @@ -31,7 +31,7 @@ export const GET = apiHandler(async () => { // matters — a delegate gets the hero only where the owner switched insights // on, never where the delegate did. Re-examine with the snapshot when // per-module scope lands; a digest is a summary of several modules at once. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index c224984a6..9874a5a53 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -54,7 +54,7 @@ export const GET = apiHandler(async () => { // // The cache cell keys on the resolved id, so the owner's snapshot lands in // the owner's cell and a delegated read neither reads nor poisons their own. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); annotate({ action: { name: "dashboard.snapshot" } }); const timings: Record = {}; diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 8ad9ffe27..4d8514d04 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -196,7 +196,7 @@ export const GET = apiHandler(async () => { // matter, which comparison baseline reads correctly for it. A delegate // opening somebody's dashboard should see it arranged the way its owner // arranged it. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); // 5-minute TTL per blueprint §5; the layout changes only when the user // hits the Settings → Dashboard save button, which invalidates this diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts index 97723cb51..6a863f3a8 100644 --- a/src/app/api/documents/inbound/[id]/original/route.ts +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -117,7 +117,7 @@ function safeDownloadName( */ export const GET = apiHandler( async (_request: Request, { params }: RouteParams) => { - const { user, actor } = await requireRecordAuth("read"); + const { user, actor } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 336adc7cb..cf0bd1f7c 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -76,7 +76,7 @@ async function enforceWriteRateLimit(userId: string): Promise { */ export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/documents/inbound/[id]/thumbnail/route.ts b/src/app/api/documents/inbound/[id]/thumbnail/route.ts index 8bab3f2f7..9afd28d86 100644 --- a/src/app/api/documents/inbound/[id]/thumbnail/route.ts +++ b/src/app/api/documents/inbound/[id]/thumbnail/route.ts @@ -52,7 +52,7 @@ const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000; */ export const GET = apiHandler( async (_request: Request, { params }: RouteParams) => { - const { user, actor } = await requireRecordAuth("read"); + const { user, actor } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 0fb17e44b..864c20229 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -458,7 +458,7 @@ export const POST = apiHandler(withIdempotency<[Request]>(postUpload)); * declare separately rather than share one line. */ export const GET = apiHandler(async (request: Request) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index 9f680e522..0b4a57c66 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -34,7 +34,7 @@ export const dynamic = "force-dynamic"; * No write arm exists on this route. */ export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts index 47da56ff4..047c70863 100644 --- a/src/app/api/family-history/[id]/route.ts +++ b/src/app/api/family-history/[id]/route.ts @@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "profile"); const { id } = await params; const row = await prisma.familyHistoryEntry.findUnique({ where: { id } }); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index f3b3e22bb..caffb79a7 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -30,7 +30,7 @@ import { import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = familyHistoryListQuerySchema.safeParse({ diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index cb53fcdf8..08e89fd41 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -47,9 +47,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // // Read-only in fact as well as in declaration: v1.35.3 moved the unlock // INSERT onto the sweep job, so there is no write for a read grant to have - // to refuse. `requireRecordAuth("read")` would refuse a non-safe method + // to refuse. `requireRecordAuth("read", "record")` would refuse a non-safe method // anyway; this file exports no other verb. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); // v1.18.0 — when the account has the achievements module turned off the // whole gamification surface disappears: no badge evaluation, no unlock diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 1f013ecb5..d735a5722 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -55,7 +55,7 @@ async function loadOwnedEpisode( export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index 9bc0df39e..a9165a279 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -30,7 +30,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index fcb41029c..bb2d90b9a 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -33,7 +33,7 @@ import { import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; @@ -78,7 +78,7 @@ async function postEpisode(request: NextRequest): Promise { // the caller: whether illness tracking exists is a property of the record, so // a delegate who runs the module on their own account does not thereby gain // an episode surface inside somebody else's. - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 5dfeb941a..50aae7591 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -43,7 +43,7 @@ export const GET = apiHandler(async () => { // switch and that is not in tension with this: chat spends the owner's AI // budget and writes into their conversation, and reading whether the thread // has something unopened does neither. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "record"); // Operator-level flag — an `AppSettings` singleton, unaffected by whose // record is open. await requireAssistantSurface("coach"); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index d83c7de99..bd1cb62d9 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -65,7 +65,7 @@ function toResolved( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "labs"); const { id } = await params; const row = await prisma.labResult.findFirst({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 6fa45b9a0..1ab53c06c 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -70,7 +70,7 @@ function toResolved( } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "labs"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listLabResultsSchema.safeParse(params); @@ -143,7 +143,7 @@ async function postLabResult(request: NextRequest) { // it mints it under `user.id`: the marker joins the record's catalogue, not // the caller's, which is the only reading of "add a result to your record" // that leaves the owner with a usable catalogue afterwards. - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "labs"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 76202e373..a463eaa67 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; const reminder = await prisma.measurementReminder.findFirst({ diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 71a813bb4..045504c64 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise { } export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const reminders = await prisma.measurementReminder.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 5862b3957..d3cd83a16 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 63b686c71..0de5467e6 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -58,7 +58,7 @@ import type { import { Prisma } from "@/generated/prisma/client"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMeasurementsSchema.safeParse(params); @@ -755,7 +755,7 @@ async function postMeasurement(request: NextRequest) { // who may or may not be that person. Nothing else in this handler reads the // caller: the safety-floor check, the reminder satisfaction, the rollup // recompute and the cache eviction are all statements about the record. - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "measurements"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts index f447b24b6..7a8e0f1c1 100644 --- a/src/app/api/measurements/series-batch/route.ts +++ b/src/app/api/measurements/series-batch/route.ts @@ -33,7 +33,7 @@ import { } from "@/lib/validations/series-batch"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = seriesBatchQuerySchema.safeParse(params); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 9233530a3..925d778a4 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -160,7 +160,7 @@ function stdDev(values: number[]): number { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const rawQuery = Object.fromEntries(request.nextUrl.searchParams); const parsed = querySchema.safeParse(rawQuery); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index dae2a9dd4..cc43cf1cf 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -42,7 +42,7 @@ const querySchema = z.object({ export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index d37b6e5bf..4a05ff73a 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -51,7 +51,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.36.x — a delegated write, the per-medication form of the same verb. // `user` is the record the dose belongs to; `actor` is whoever pressed the // button, and is used for nothing but the owner's notification below. - const { user, actor, authMethod } = await requireRecordAuth("write"); + const { user, actor, authMethod } = await requireRecordAuth( + "write", + "medications", + ); // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` // refuses a delegate changing a dose the owner already recorded. This route @@ -588,7 +591,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index ea361cabf..772b3fb49 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index ad5883877..6ed2937cf 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 2a9c5b9a6..877ef0c41 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -76,7 +76,7 @@ function reconcileScheduleWindow( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; const medication = await prisma.medication.findUnique({ diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index b91699a4d..5a227284a 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{ export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 4dac28ec5..b3f32e032 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -46,7 +46,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); @@ -100,7 +100,7 @@ export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.36.x — a delegated write. `user` is the record the entry lands under; // `actor` is whoever is typing, and the two differ only under a switch. - const { user, actor } = await requireRecordAuth("write"); + const { user, actor } = await requireRecordAuth("write", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index f120a7a35..a015e07f0 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -26,7 +26,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; * detail page keeps the per-id route for the grid. */ export const GET = apiHandler(async () => { - const { user, actor } = await requireRecordAuth("read"); + const { user, actor } = await requireRecordAuth("read", "medications"); // v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to // the record. Two reasons, and both are the same reason from opposite ends: diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index cc3023de6..4c39b59d7 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -85,7 +85,7 @@ const updateSchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); const parsed = querySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), @@ -226,7 +226,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // address the delegate, and none of them should — the delegate's feedback is // the response they are already awaiting. What the owner gets instead is the // notification at the end of this handler. - const { user, actor } = await requireRecordAuth("write"); + const { user, actor } = await requireRecordAuth("write", "medications"); const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 382973206..99330b11f 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -84,7 +84,7 @@ export const GET = apiHandler(async () => { // owner's list would fall back to default order — a preference that cannot // apply is not a preference, it is noise. `/medications` is a shared // destination, so this is a list a delegate really does see. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); // 5-minute TTL matches the dashboard-widgets / insights-layout caches; the // blob changes only on a view toggle or an order save, which invalidates via diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index e80675d2f..b4a82bafc 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -33,7 +33,7 @@ import { NextRequest } from "next/server"; const APPLE_HEALTH_MIRROR_CAP = 30; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); // Cache the list shape on userId. The 60 s fresh TTL bounds the // cross-midnight staleness window for `todayEventCount` and the @@ -98,7 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay // on `requireAuth()` and refuse under a switch. Adding is help; rewriting // the owner's cabinet is not. - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "medications"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index b2777ab47..7dc6d9dfd 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -99,7 +99,7 @@ function respondExisting(row: Parameters[0]): Response { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "mind"); // Opt-in module (default OFF): the screener history is unreachable until the // account turns the mental-health module on. Enforced server-side even for a // valid Bearer token (the nav entry / page redirect are UX-only). diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index a9166ad71..723b1c15c 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "mind"); const { id } = await params; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 699baac0b..5fdac86c8 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "mind"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMoodEntriesSchema.safeParse(params); diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts index 5e1b561ce..69758e324 100644 --- a/src/app/api/nutrients/daily/route.ts +++ b/src/app/api/nutrients/daily/route.ts @@ -34,7 +34,7 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const gate = await requireModuleEnabled(user.id, "nutrients"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 0bc423720..73d459334 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -92,7 +92,7 @@ function pickTopSkipReason(byReason: Record): string | null { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); const gate = await requireModuleEnabled(user.id, "nutrients"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 54847fce1..0900e3cf7 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100; const MAX_LIMIT = 500; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); annotate({ action: { name: "personalRecords.list" } }); const { searchParams } = new URL(request.url); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts index bf679a0f4..afa28504c 100644 --- a/src/app/api/settings/reminder-thresholds/route.ts +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -24,7 +24,7 @@ export const GET = apiHandler(async () => { // // No write arm exists, so there is nothing to split: a delegate cannot move // the owner's threshold, only read the one the owner set. - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "medications"); // Operator-level singleton (`lateMinutes` / `missedMinutes`) — the same for // every account on the deployment, so the switch does not touch it. diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 283fa25f5..fc8c429ba 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -105,7 +105,7 @@ function serializeSession(s: SleepSession) { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); // Per-domain gate: the hypnogram read serves only the sleep module's // view surfaces, so it gates on the sleep module. Disabled ⇒ 403. diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts index 007a154b0..c4e0bc677 100644 --- a/src/app/api/sleep/rhythm/route.ts +++ b/src/app/api/sleep/rhythm/route.ts @@ -26,7 +26,7 @@ import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm"; import { requireModuleEnabled } from "@/lib/modules/gate"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); // Per-domain gate: the sleep-rhythm read serves only the sleep module's // view surfaces (Sleep page + iOS), so it gates on the sleep module. diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index ca5bd6623..685a56771 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -70,7 +70,7 @@ const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); annotate({ action: { name: "workouts.list" } }); // v1.18.0 B1 — the workouts surface is a toggleable module. A disabled diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index 0bb2e006d..fac096103 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -31,14 +31,19 @@ interface GrantRow { id: string; grantorId: string; granteeId: string; - access: "READ" | "WRITE"; + access: "READ" | "WRITE" | "MANAGE"; + /** v1.37.0 — NULL is the entire record; an array narrows to those sections. */ + scopeJson: string[] | null; acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date | null; } const grantRows: GrantRow[] = []; -const userRows = new Map(); +const userRows = new Map< + string, + { id: string; role: string; managedProfileAt: Date | null } +>(); const touched: string[] = []; vi.mock("@/lib/db", () => ({ @@ -112,6 +117,7 @@ import { ACCOUNT_SELECTOR_HEADER, apiHandler, requireActorAuth, + requireGuardianAuth, requireAdmin, requireAuth, requireCookieAuth, @@ -128,8 +134,18 @@ import type { WideEvent } from "@/lib/logging/types"; const OWNER = "owner-1"; const DELEGATE = "delegate-1"; -function user(id: string, role: "USER" | "ADMIN" = "USER") { - return { id, role, username: id, email: `${id}@example.test` }; +function user( + id: string, + role: "USER" | "ADMIN" = "USER", + managedProfileAt: Date | null = null, +) { + return { + id, + role, + username: id, + email: `${id}@example.test`, + managedProfileAt, + }; } /** Sign the delegate in over the cookie transport. */ @@ -172,6 +188,7 @@ function liveGrant(overrides: Partial = {}): GrantRow { grantorId: OWNER, granteeId: DELEGATE, access: "READ", + scopeJson: null, acceptedAt: new Date(Date.now() - 60_000), revokedAt: null, expiresAt: null, @@ -252,7 +269,9 @@ describe("do no harm — nothing is acting as anyone", () => { const actor = await resolveInRoute(() => requireActorAuth()); expect(actor.user.id).toBe(DELEGATE); - const record = await resolveInRoute(() => requireRecordAuth("read")); + const record = await resolveInRoute(() => + requireRecordAuth("read", "measurements"), + ); expect(record.user.id).toBe(DELEGATE); expect(record.actor.id).toBe(DELEGATE); expect(record.grantId).toBeNull(); @@ -266,7 +285,7 @@ describe("do no harm — nothing is acting as anyone", () => { it("leaves the wide event's actor identity alone", async () => { signedInCookie(null); - const call = route(() => requireRecordAuth("read")); + const call = route(() => requireRecordAuth("read", "measurements")); await call(); expect(lastEvent().auth?.user_id).toBe(DELEGATE); @@ -382,7 +401,7 @@ describe("requireRecordAuth — substitution", () => { const grant = liveGrant(); const resolved: RecordAuthContext = await resolveInRoute(() => - requireRecordAuth("read"), + requireRecordAuth("read", "measurements"), ); expect(resolved.user.id).toBe(OWNER); expect(resolved.actor.id).toBe(DELEGATE); @@ -394,7 +413,9 @@ describe("requireRecordAuth — substitution", () => { selector(OWNER); liveGrant(); - const resolved = await resolveInRoute(() => requireRecordAuth("read")); + const resolved = await resolveInRoute(() => + requireRecordAuth("read", "measurements"), + ); expect(resolved.user.id).toBe(OWNER); expect(resolved.actor.id).toBe(DELEGATE); }); @@ -403,7 +424,7 @@ describe("requireRecordAuth — substitution", () => { signedInCookie(OWNER); liveGrant(); - await route(() => requireRecordAuth("read"))(); + await route(() => requireRecordAuth("read", "measurements"))(); expect(lastEvent().auth?.user_id).toBe(DELEGATE); expect(lastEvent().auth?.acting_as).toBe(OWNER); }); @@ -412,7 +433,7 @@ describe("requireRecordAuth — substitution", () => { signedInCookie(OWNER); const grant = liveGrant(); - await resolveInRoute(() => requireRecordAuth("read")); + await resolveInRoute(() => requireRecordAuth("read", "measurements")); await new Promise((r) => setTimeout(r, 0)); expect(touched).toEqual([grant.id]); }); @@ -420,7 +441,9 @@ describe("requireRecordAuth — substitution", () => { describe("requireRecordAuth — every way a grant can fail", () => { const refused = async (): Promise => { - const response = await route(() => requireRecordAuth("read"))(); + const response = await route(() => + requireRecordAuth("read", "measurements"), + )(); expect(response.status).toBe(403); expect((await response.clone().json()).meta.errorCode).toBe( "sharing.access.denied", @@ -456,7 +479,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { signedInCookie(OWNER); liveGrant(); - const response = await route(() => requireRecordAuth("read"))("POST"); + const response = await route(() => + requireRecordAuth("read", "measurements"), + )("POST"); expect(response.status).toBe(403); expect((await response.json()).meta.errorCode).toBe( "sharing.access.denied", @@ -467,7 +492,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { signedInCookie(OWNER); liveGrant(); - const response = await route(() => requireRecordAuth("write"))(); + const response = await route(() => + requireRecordAuth("write", "measurements"), + )(); expect(response.status).toBe(403); }); @@ -478,7 +505,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { signedInCookie(OWNER); liveGrant({ access: "WRITE" }); - const response = await route(() => requireRecordAuth("write"))("POST"); + const response = await route(() => + requireRecordAuth("write", "measurements"), + )("POST"); expect(response.status).toBe(200); }); @@ -488,7 +517,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { // guard. signedInCookie(OWNER); liveGrant(); - await expect(requireRecordAuth("read")).rejects.toMatchObject({ + await expect( + requireRecordAuth("read", "measurements"), + ).rejects.toMatchObject({ errorCode: "sharing.access.denied", }); }); @@ -496,7 +527,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { it("refuses a selector too long to name an account, without querying", async () => { signedInBearer(); selector("x".repeat(65)); - await expect(requireRecordAuth("read")).rejects.toMatchObject({ + await expect( + requireRecordAuth("read", "measurements"), + ).rejects.toMatchObject({ errorCode: "sharing.access.denied", }); expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled(); @@ -506,7 +539,9 @@ describe("requireRecordAuth — every way a grant can fail", () => { signedInCookie(null); selector(OWNER); liveGrant(); - await expect(requireRecordAuth("read")).rejects.toMatchObject({ + await expect( + requireRecordAuth("read", "measurements"), + ).rejects.toMatchObject({ errorCode: "sharing.not_permitted", }); }); @@ -526,7 +561,9 @@ describe("refusals do not enumerate", () => { it("answers identically for an account that does not exist and one that granted nothing", async () => { signedInBearer(); selector("no-such-account-at-all"); - const missing = await route(() => requireRecordAuth("read"))(); + const missing = await route(() => + requireRecordAuth("read", "measurements"), + )(); const missingBody = await missing.text(); vi.clearAllMocks(); @@ -536,7 +573,9 @@ describe("refusals do not enumerate", () => { // granted this caller anything. userRows.set("real-stranger", user("real-stranger")); selector("real-stranger"); - const present = await route(() => requireRecordAuth("read"))(); + const present = await route(() => + requireRecordAuth("read", "measurements"), + )(); const presentBody = await present.text(); expect(missing.status).toBe(present.status); @@ -549,7 +588,9 @@ describe("refusals do not enumerate", () => { signedInBearer(); selector(OWNER); liveGrant({ revokedAt: new Date(Date.now() - 1000) }); - const revoked = await route(() => requireRecordAuth("read"))(); + const revoked = await route(() => + requireRecordAuth("read", "measurements"), + )(); const revokedBody = await revoked.text(); vi.clearAllMocks(); @@ -557,7 +598,9 @@ describe("refusals do not enumerate", () => { grantRows.length = 0; signedInBearer(); selector(OWNER); - const never = await route(() => requireRecordAuth("read"))(); + const never = await route(() => + requireRecordAuth("read", "measurements"), + )(); expect(revokedBody).toBe(await never.text()); }); @@ -567,7 +610,9 @@ describe("refusals do not enumerate", () => { selector(OWNER); liveGrant({ expiresAt: new Date(Date.now() - 1000) }); - const body = await (await route(() => requireRecordAuth("read"))()).text(); + const body = await ( + await route(() => requireRecordAuth("read", "measurements"))() + ).text(); expect(body).not.toContain("expire"); expect(body).not.toContain("grant"); expect(sharingAuditCalls()[0][1]).toMatchObject({ @@ -618,3 +663,236 @@ describe("the cookie-only helpers stay out of reach of a switch", () => { expect(ctx.user.id).toBe(DELEGATE); }); }); + +/** + * v1.37.0 — the section fence. + * + * A scoped grant is a door policy: the sections it names open, the others do + * not answer at all. Every refusal below is the same 403 with the same body as + * every other sharing refusal — only the audit reason differs — so these + * assert the reason on the row and the bytes on the wire separately. + */ +describe("a scoped grant reaches only the sections it names", () => { + it("admits a route whose section the grant names", async () => { + signedInCookie(OWNER); + liveGrant({ scopeJson: ["medications"] }); + + const ctx = await resolveInRoute(() => + requireRecordAuth("read", "medications"), + ); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + }); + + it("refuses a route whose section the grant does not name", async () => { + signedInCookie(OWNER); + liveGrant({ scopeJson: ["medications"] }); + + const response = await route(() => + requireRecordAuth("read", "measurements"), + )(); + expect(response.status).toBe(403); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "out_of_scope" }), + }); + }); + + it("refuses every record-wide route, at any scope", async () => { + // The invariant: an aggregate reads across sections and there is no honest + // partial answer to one. Not filtered — refused. + signedInCookie(OWNER); + liveGrant({ scopeJson: ["medications", "measurements", "labs"] }); + + const response = await route(() => requireRecordAuth("read", "record"))(); + expect(response.status).toBe(403); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "out_of_scope" }), + }); + }); + + it("refuses everything when the stored scope is unreadable", async () => { + // Fail-closed, end to end: a blob the normaliser cannot read opens + // nothing, including a section the blob might have happened to name. + signedInCookie(OWNER); + liveGrant({ scopeJson: "medications" as unknown as string[] }); + + for (const domain of ["medications", "measurements", "record"] as const) { + const response = await route(() => requireRecordAuth("read", domain))(); + expect(response.status, domain).toBe(403); + } + }); + + it("leaves a NULL-scope grant reaching everything, as before", async () => { + // The no-regression leg. Every grant in the product is this one. + signedInCookie(OWNER); + liveGrant({ scopeJson: null }); + + for (const domain of [ + "measurements", + "medications", + "labs", + "profile", + "illness", + "mind", + "cycle", + "documents", + "record", + ] as const) { + const ctx = await resolveInRoute(() => requireRecordAuth("read", domain)); + expect(ctx.user.id, domain).toBe(OWNER); + } + }); + + it("says the same thing on the wire as every other refusal", async () => { + signedInCookie(OWNER); + liveGrant({ scopeJson: ["medications"] }); + const outOfScope = await route(() => + requireRecordAuth("read", "measurements"), + )(); + const outOfScopeBody = await outOfScope.text(); + + grantRows.length = 0; + liveGrant({ access: "READ" }); + const insufficient = await route(() => + requireRecordAuth("write", "measurements"), + )(); + + expect(outOfScope.status).toBe(insufficient.status); + expect(outOfScopeBody).toBe(await insufficient.text()); + expect(outOfScopeBody).not.toContain("scope"); + expect(outOfScopeBody).not.toContain("section"); + }); +}); + +/** + * v1.37.0 — the third level, at the resolver. + */ +describe("a route declaring manage refuses everything below it", () => { + it("admits a MANAGE grant", async () => { + signedInCookie(OWNER); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute( + () => requireRecordAuth("manage", "measurements"), + "DELETE", + ); + expect(ctx.user.id).toBe(OWNER); + }); + + it("refuses a WRITE grant on a safe method", async () => { + // The method cannot satisfy a declaration, only escalate one. A GET on a + // manage-declaring route is still a manage question. + signedInCookie(OWNER); + liveGrant({ access: "WRITE" }); + + const response = await route(() => + requireRecordAuth("manage", "measurements"), + )("GET"); + expect(response.status).toBe(403); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "insufficient_access" }), + }); + }); + + it("lets a MANAGE grant satisfy a route that only asked for a write", async () => { + signedInCookie(OWNER); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute( + () => requireRecordAuth("write", "labs"), + "POST", + ); + expect(ctx.user.id).toBe(OWNER); + }); +}); + +/** + * v1.37.0 — the identity fence. + * + * The single most important arm in the release: the line between "manage my + * data" and "own my account". An invited adult's MANAGE grant reaches the + * record's health data and NONE of its settings, integrations, routing, grant + * management or deletion. The gate is the managed-profile marker on the + * record, not the level on the grant. + */ +describe("the guardian resolver gates on the marker, not the grant", () => { + it("admits a MANAGE grant on a marked record", async () => { + signedInCookie(OWNER); + userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z"))); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute(() => requireGuardianAuth()); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + expect(ctx.grantId).toBe("grant-1"); + }); + + it("refuses a MANAGE grant on an ordinary adult's record", async () => { + // The fence. The grant is perfect and the answer is still no, because the + // record has an owner who runs it. + signedInCookie(OWNER); + userRows.set(OWNER, user(OWNER, "USER", null)); + liveGrant({ access: "MANAGE" }); + + const response = await route(() => requireGuardianAuth())(); + expect(response.status).toBe(403); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "guardian_only" }), + }); + }); + + it("refuses a WRITE grant on a marked record", async () => { + // The marker is necessary and not sufficient either way round. + signedInCookie(OWNER); + userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z"))); + liveGrant({ access: "WRITE" }); + + const response = await route(() => requireGuardianAuth())(); + expect(response.status).toBe(403); + expect(sharingAuditCalls()[0][1]).toMatchObject({ + details: expect.objectContaining({ reason: "insufficient_access" }), + }); + }); + + it("ignores the scope on a marked record, because MANAGE carries none", async () => { + signedInCookie(OWNER); + userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z"))); + liveGrant({ access: "MANAGE", scopeJson: null }); + + const ctx = await resolveInRoute(() => requireGuardianAuth()); + expect(ctx.user.id).toBe(OWNER); + }); + + it("serves the caller's own record when nothing is acting as anyone", async () => { + // Do no harm: the owner of an ordinary record reaching their own settings + // is the request it always was, and no grant question is asked. + signedInCookie(null); + + const ctx = await resolveInRoute(() => requireGuardianAuth()); + expect(ctx.user.id).toBe(DELEGATE); + expect(ctx.grantId).toBeNull(); + expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled(); + }); + + it("says the same thing on the wire as every other refusal", async () => { + signedInCookie(OWNER); + userRows.set(OWNER, user(OWNER, "USER", null)); + liveGrant({ access: "MANAGE" }); + const fenced = await route(() => requireGuardianAuth())(); + const fencedBody = await fenced.text(); + + // Compared against the oldest refusal in the feature: no grant at all. The + // two are as far apart in meaning as this feature gets — "you were never + // given anything" and "you were given management of a record whose owner + // runs it" — and the caller must not be able to tell them apart. + grantRows.length = 0; + const insufficient = await route(() => + requireRecordAuth("read", "measurements"), + )(); + + expect(fenced.status).toBe(insufficient.status); + expect(fencedBody).toBe(await insufficient.text()); + expect(fencedBody).not.toContain("guardian"); + expect(fencedBody).not.toContain("managed"); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 5c711ad16..ed0d85de5 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; -import type { User } from "@/generated/prisma/client"; +import type { AccountGrant, User } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { WideEventBuilder } from "./logging/event-builder"; import { annotate, eventStorage, getEvent } from "./logging/context"; @@ -32,9 +32,11 @@ import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; import { findActiveGrant, grantAllows, + grantCoversDomain, touchGrantUsage, type GrantNeed, } from "./sharing/grants"; +import type { ShareScope } from "./sharing/scope"; /** * HTTP methods a read-only credential may use on the REST surface. A request @@ -770,10 +772,20 @@ export interface RecordAuthContext extends AuthContext { * method must BOTH be satisfiable, so a delegable GET handler that grows a * POST export cannot quietly inherit the read grant's permission. An unknown * method (no event context) counts as a write and is refused, the same - * fail-closed posture as the MCP audience guard above. + * fail-closed posture as the MCP audience guard above. A route declaring + * `"manage"` refuses everything below it regardless of method: the method + * can only escalate what the declaration asked for, never satisfy it. + * @param domain v1.37.0 — which section of the record the route touches, or + * `"record"` when it reads across sections. Required, and the requirement is + * the fail-closed lever: a delegable route without a classification does not + * typecheck, so the set of routes carrying one cannot fall behind the set of + * routes that are delegable. The value is frozen per module by + * `src/__tests__/delegable-surface-guard.test.ts` and reviewed against the + * design's clustering table; this function only enforces what was declared. */ export async function requireRecordAuth( need: GrantNeed, + domain: ShareScope, ): Promise { const auth = await authenticateCaller(); const carrier = await readActingCarrier(auth); @@ -789,6 +801,101 @@ export async function requireRecordAuth( throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); } + const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); + const effectiveNeed: GrantNeed = + need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read"; + + return resolveSwitchedRecord(auth, carrier, (grant) => { + if (!grantAllows(grant, effectiveNeed)) return "insufficient_access"; + // Level first, then scope, and the order is visible on the wire in exactly + // one way: not at all. Both refusals are the same error with the same + // bytes, and only the audit reason differs — so the ordering is a choice + // about what the operator's trail says happened, not about what the caller + // learns. It says the bigger thing: a delegate who was never given this + // level is a different story from one whose sections do not reach here. + if (!grantCoversDomain(grant, domain)) return "out_of_scope"; + return null; + }); +} + +/** + * The need a non-safe method implies, for a route that declared `need`. + * + * A PUT on a route declaring `"read"` needs a write grant, which is what this + * has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant + * and must not be reduced to a write — so the escalation raises the floor to + * `"write"` and never lowers a declaration that already sits above it. + */ +function escalate(need: GrantNeed): GrantNeed { + return need === "manage" ? "manage" : "write"; +} + +/** + * v1.37.0 — a guardian surface: this route administers a managed profile. + * + * The other declaration, and a separate function rather than a flag on the one + * above, for the reason `requireActorAuth` is also separate: the guard freezes + * each declaration as its own list, so a route moving between them is a diff a + * human reviews rather than an argument about a parameter. + * + * What it admits is narrower than it looks. An active MANAGE grant is + * necessary and NOT sufficient — the record must also carry the managed-profile + * marker. That second condition is the identity fence, and it is the line + * between "manage my data" and "own my account": an adult who granted + * management of their own record reaches none of this, at any level, by any + * argument, because their record has no marker and never will while they hold + * it. A managed profile has no self to reserve these surfaces for, so its + * guardian holds them. + * + * The fence is gated on the marker rather than on the grant precisely so that + * no future change to what MANAGE means can reach through it. Widening MANAGE + * would widen `requireRecordAuth`; it would not touch this function. + */ +export async function requireGuardianAuth(): Promise { + const auth = await authenticateCaller(); + const carrier = await readActingCarrier(auth); + + if (carrier.kind === "none") { + // The owner of an ordinary record, reaching their own settings. Same + // do-no-harm posture as the record resolver: without a carrier this is the + // request it always was. + return { ...auth, actor: auth.user, grantId: null }; + } + if (carrier.kind === "misplaced-header") { + throw refuseUndeclaredMode(auth, carrier, "misplaced_selector"); + } + + return resolveSwitchedRecord(auth, carrier, (grant, owner) => { + if (!grantAllows(grant, "manage")) return "insufficient_access"; + if (owner.managedProfileAt === null) return "guardian_only"; + return null; + }); +} + +/** + * The shared body of both record resolvers: turn a selector into an owner, or + * refuse. + * + * Everything either resolver does with a carrier lives here except the + * admission rule itself, which arrives as `admit` — a function of the grant + * and the owner returning an audit reason to refuse with, or null to proceed. + * The split is where it is because the two resolvers differ in exactly that + * one decision and in nothing else, and two copies of the grant lookup, the + * refusal shape, the usage stamp and the owner's access row would be two + * copies to keep in step. + * + * The owner row is loaded BEFORE the admission runs, which is a change from + * v1.36.0's ordering and a deliberate one: the guardian rule is a fact about + * the owner, so it cannot be decided before the owner is known. Nothing about + * the refusal moved — every arm below is the same error with the same bytes, + * and an admission that refuses after the load costs one query it does not use + * and tells the caller nothing extra. + */ +async function resolveSwitchedRecord( + auth: AuthContext, + carrier: Extract, + admit: (grant: AccountGrant, owner: User) => string | null, +): Promise { /** Every refusal below is this one error, with the reason kept off the wire. */ const denied = (reason: string): SharingAccessDeniedError => { annotate({ meta: { sharing_refusal: reason } }); @@ -800,10 +907,6 @@ export async function requireRecordAuth( throw denied("malformed_selector"); } - const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase(); - const effectiveNeed: GrantNeed = - need === "write" || !READ_HTTP_METHODS.has(method) ? "write" : "read"; - // Loaded here, on this request, every request. Not memoised on the session, // not cached in the process, not carried on the token: a revocation has to // land on the delegate's NEXT request, not on their next login. That is the @@ -818,7 +921,6 @@ export async function requireRecordAuth( granteeId: auth.user.id, }); if (!grant) throw denied("no_active_grant"); - if (!grantAllows(grant, effectiveNeed)) throw denied("insufficient_access"); const owner = await prisma.user.findUnique({ where: { id: grant.grantorId }, @@ -828,6 +930,9 @@ export async function requireRecordAuth( // to a person is not one to act on. if (!owner) throw denied("owner_missing"); + const refusal = admit(grant, owner); + if (refusal !== null) throw denied(refusal); + getEvent()?.setActingAs(owner.id); // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait // on the bookkeeping, and the bookkeeping failing must not fail the read. diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index e5766e289..811e4b034 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -379,6 +379,8 @@ export const USER_KEPT_FIELDS: Readonly> = { oidcSub: "a sign-in credential — the external identity binding", createdAt: "when the account was created; the account is preserved", updatedAt: "row bookkeeping maintained by Prisma", + managedProfileAt: + "whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.", totpSecretEncrypted: "a second-factor credential", totpConfirmedAt: "second-factor enrolment state", totpLastStep: diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index 86153cf7a..7dd666f9d 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -15,12 +15,16 @@ import { describe, expect, it } from "vitest"; import { grantAllows, + grantCoversDomain, grantState, inviteGrant, isGrantActive, + normaliseScope, type GrantLifecycle, } from "@/lib/sharing/grants"; -import type { AccountGrant, Prisma } from "@/generated/prisma/client"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { Prisma } from "@/generated/prisma/client"; +import type { AccountGrant } from "@/generated/prisma/client"; const NOW = new Date("2026-08-02T12:00:00.000Z"); const EARLIER = new Date("2026-08-01T12:00:00.000Z"); @@ -151,17 +155,28 @@ describe("grantAllows", () => { describe("inviteGrant", () => { function captureDb(): { db: Pick; - writes: Array<{ access: string; grantorId: string; granteeId: string }>; + writes: Array<{ + access: string; + grantorId: string; + granteeId: string; + scopeJson: unknown; + }>; } { const writes: Array<{ access: string; grantorId: string; granteeId: string; + scopeJson: unknown; }> = []; const db = { accountGrant: { create: async (args: { - data: { access: string; grantorId: string; granteeId: string }; + data: { + access: string; + grantorId: string; + granteeId: string; + scopeJson: unknown; + }; }) => { writes.push(args.data); return { id: "grant-1", ...args.data } as unknown as AccountGrant; @@ -175,7 +190,7 @@ describe("inviteGrant", () => { for (const access of ["READ", "WRITE"] as const) { const { db, writes } = captureDb(); const row = await inviteGrant( - { grantorId: "owner", granteeId: "delegate", access }, + { grantorId: "owner", granteeId: "delegate", access, scope: null }, db, ); expect(writes).toHaveLength(1); @@ -188,7 +203,7 @@ describe("inviteGrant", () => { const { db, writes } = captureDb(); await expect( inviteGrant( - { grantorId: "solo", granteeId: "solo", access: "WRITE" }, + { grantorId: "solo", granteeId: "solo", access: "WRITE", scope: null }, db, ), ).rejects.toMatchObject({ code: "self_grant" }); @@ -212,3 +227,241 @@ describe("inviteGrant", () => { ); }); }); + +/** + * v1.37.0 — the third level. + * + * `grantAllows` is a total order over three levels now, and the one comparison + * that carries the level's meaning is the one that must NOT hold: an accepted + * WRITE grant carries a consent to add, and nothing about it carries a consent + * to rewrite or remove. + */ +describe("grantAllows, at MANAGE", () => { + it("lets a MANAGE grant do all three", () => { + const manage = { ...grant(), access: "MANAGE" as const }; + expect(grantAllows(manage, "read", NOW)).toBe(true); + expect(grantAllows(manage, "write", NOW)).toBe(true); + expect(grantAllows(manage, "manage", NOW)).toBe(true); + }); + + it("refuses a WRITE grant the manage need", () => { + const write = { ...grant(), access: "WRITE" as const }; + expect(grantAllows(write, "write", NOW)).toBe(true); + expect(grantAllows(write, "manage", NOW)).toBe(false); + }); + + it("refuses a READ grant the manage need", () => { + expect(grantAllows({ ...grant(), access: "READ" }, "manage", NOW)).toBe( + false, + ); + }); + + it("refuses every need on a MANAGE grant that is not active", () => { + // The state outranks the level, at the top of the order as at the bottom. + for (const ended of [ + grant({ revokedAt: EARLIER }), + grant({ acceptedAt: null }), + grant({ expiresAt: EARLIER }), + ]) { + const dead = { ...ended, access: "MANAGE" as const }; + expect(grantAllows(dead, "read", NOW)).toBe(false); + expect(grantAllows(dead, "write", NOW)).toBe(false); + expect(grantAllows(dead, "manage", NOW)).toBe(false); + } + }); +}); + +/** + * The fail-closed normaliser. + * + * This is the one function in the feature whose failure mode is silent and + * wide: a value it misreads as permissive opens sections the owner never + * ticked, on every request, with nothing to see. So the tests below are + * mostly about garbage, and every one of them asserts the same thing — that + * a value the function cannot read opens NOTHING. + */ +describe("normaliseScope", () => { + it("reads NULL as the entire record", () => { + // Not a default standing in for a missing answer: it is the answer every + // grant written before the column existed was consented as. + expect(normaliseScope(null)).toBeNull(); + }); + + it("reads a well-formed array as exactly those sections", () => { + const scope = normaliseScope(["labs", "medications"]); + expect(scope).not.toBeNull(); + expect([...(scope as ReadonlySet)].sort()).toEqual([ + "labs", + "medications", + ]); + }); + + it("accepts every member of the production vocabulary", () => { + // Non-zero proof for the negatives below: the function CAN say yes, to + // each of the eight, so a wholesale refusal would fail here rather than + // read as caution. + expect(SHARE_DOMAINS.length).toBeGreaterThan(0); + for (const domain of SHARE_DOMAINS) { + expect([...(normaliseScope([domain]) as ReadonlySet)]).toEqual([ + domain, + ]); + } + }); + + it("resolves every malformed value to the empty set", () => { + const garbage: Prisma.JsonValue[] = [ + "garbage", + "", + 42, + true, + false, + {}, + { measurements: true }, + [], + ["measurements", "not-a-domain"], + ["not-a-domain"], + ["record"], + [null], + [1], + [["measurements"]], + [{ key: "measurements" }], + ]; + + for (const blob of garbage) { + const scope = normaliseScope(blob); + // Not null — null is "everything", and that is the answer this function + // must never give to something it could not read. + expect(scope, JSON.stringify(blob)).not.toBeNull(); + expect((scope as ReadonlySet).size, JSON.stringify(blob)).toBe(0); + } + }); + + it("gives no partial credit to a half-recognisable set", () => { + // The tempting reading is "honour what we understand". It is the wrong + // one: a set written in a shape this file does not understand is not a + // consent to guess at. + const scope = normaliseScope(["labs", "medications", "unknown"]); + expect((scope as ReadonlySet).has("labs")).toBe(false); + expect((scope as ReadonlySet).size).toBe(0); + }); +}); + +describe("grantCoversDomain", () => { + it("covers every section, and the record, when the scope is NULL", () => { + for (const domain of SHARE_DOMAINS) { + expect(grantCoversDomain({ scopeJson: null }, domain)).toBe(true); + } + expect(grantCoversDomain({ scopeJson: null }, "record")).toBe(true); + }); + + it("covers the sections it names and no others", () => { + const scoped = { scopeJson: ["medications"] }; + expect(grantCoversDomain(scoped, "medications")).toBe(true); + expect(grantCoversDomain(scoped, "measurements")).toBe(false); + expect(grantCoversDomain(scoped, "labs")).toBe(false); + }); + + it("never covers the record, however wide the scope", () => { + // The invariant. A route declaring `record` reads across sections, and + // there is no honest answer to one of those for a delegate who was given + // part of a record — a score that says 70 to the owner and 64 to the + // delegate is a support case and a clinical hazard. Even a scope naming + // all eight sections is refused: it is a narrowing, and a narrowing is + // not the whole. + expect(grantCoversDomain({ scopeJson: [...SHARE_DOMAINS] }, "record")).toBe( + false, + ); + }); + + it("covers nothing at all when the stored scope is unreadable", () => { + const broken = { scopeJson: "garbage" }; + for (const domain of SHARE_DOMAINS) { + expect(grantCoversDomain(broken, domain)).toBe(false); + } + expect(grantCoversDomain(broken, "record")).toBe(false); + }); + + it("does not cover a section that postdates the invitation", () => { + // A grant written when the vocabulary was shorter opens what it named and + // nothing added since, which is what makes a new section a consent + // question rather than a schema question. + expect(grantCoversDomain({ scopeJson: ["labs"] }, "cycle")).toBe(false); + }); +}); + +describe("inviteGrant, on scope", () => { + function captureScopeDb(): { + db: Pick; + writes: Array<{ scopeJson: unknown }>; + } { + const writes: Array<{ scopeJson: unknown }> = []; + const db = { + accountGrant: { + create: async (args: { data: { scopeJson: unknown } }) => { + writes.push(args.data); + return { id: "grant-1", ...args.data } as unknown as AccountGrant; + }, + }, + } as unknown as Pick; + return { db, writes }; + } + + const pair = { grantorId: "owner", granteeId: "delegate" } as const; + + it("writes SQL NULL, not the JSON value null, for the entire record", () => { + // One keystroke apart and opposite in meaning: a stored JSON `null` is not + // an array, so it would resolve to the empty set — a grant that opens + // nothing, written by the path meant to open everything. + const { db, writes } = captureScopeDb(); + return inviteGrant({ ...pair, access: "READ", scope: null }, db).then( + () => { + expect(writes).toHaveLength(1); + expect(writes[0].scopeJson).toBe(Prisma.DbNull); + expect(writes[0].scopeJson).not.toBeNull(); + }, + ); + }); + + it("writes the sections it was given, deduplicated", async () => { + const { db, writes } = captureScopeDb(); + await inviteGrant( + { ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] }, + db, + ); + expect(writes[0].scopeJson).toEqual(["labs", "medications"]); + }); + + it("refuses a scope that cannot mean anything, before anything is written", async () => { + const refused = [ + { access: "READ" as const, scope: [] }, + { + access: "READ" as const, + scope: ["not-a-domain"] as unknown as ["labs"], + }, + { access: "READ" as const, scope: ["record"] as unknown as ["labs"] }, + // Whole-record by construction: management of part of a record is a + // boundary the product does not promise, so this file does not store it. + { access: "MANAGE" as const, scope: ["labs" as const] }, + ]; + + for (const input of refused) { + const { db, writes } = captureScopeDb(); + await expect( + inviteGrant({ ...pair, ...input }, db), + JSON.stringify(input), + ).rejects.toMatchObject({ code: "invalid_scope" }); + // The refusal did not apply: nothing reached the table. + expect(writes, JSON.stringify(input)).toHaveLength(0); + } + }); + + it("still mints a MANAGE grant when no scope is named", async () => { + // The negative above is about the pairing, not about the level. Without + // this leg the four refusals could all be "MANAGE is refused", which is + // not the rule. + const { db, writes } = captureScopeDb(); + await inviteGrant({ ...pair, access: "MANAGE", scope: null }, db); + expect(writes).toHaveLength(1); + expect(writes[0].scopeJson).toBe(Prisma.DbNull); + }); +}); diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 7c984ab91..1bd2b1285 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -37,11 +37,13 @@ * the shape it does. Re-inviting the same person mints a NEW row; the * partial unique index in migration 0292 keeps at most one LIVE row per * pair while the history piles up underneath. - * * **The level never widens.** A row is READ or WRITE from the moment it is - * offered, and no transition here raises it. Widening a grant somebody has - * already accepted would change what they agreed to without asking them - * again, and the delegate's consent is half of what makes a write grant - * legitimate. {@link inviteGrant} carries the reasoning. + * * **The level never widens, and neither does the scope.** A row is READ, + * WRITE or MANAGE from the moment it is offered, over the sections it + * named at that moment, and no transition here raises either. Widening a + * grant somebody has already accepted would change what they agreed to + * without asking them again, and the delegate's consent is half of what + * makes a write grant legitimate. {@link inviteGrant} carries the + * reasoning. * * Every write below is a CONDITIONAL update — the state the transition * requires is in the `where`, not in a read the caller performed a moment @@ -52,10 +54,12 @@ import { prisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; +import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope"; +import type { ShareDomain, ShareScope } from "@/lib/sharing/scope"; +import { Prisma } from "@/generated/prisma/client"; import type { AccountGrant, AccountGrantAccess, - Prisma, } from "@/generated/prisma/client"; /** The slice of a grant row the pure predicates read. */ @@ -77,7 +81,7 @@ export interface GrantLifecycle { export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE"; /** What a caller needs the grant to permit. */ -export type GrantNeed = "read" | "write"; +export type GrantNeed = "read" | "write" | "manage"; export type GrantErrorCode = /** An account cannot share its record with itself. */ @@ -95,7 +99,13 @@ export type GrantErrorCode = /** Accept ran against an invitation that had already lapsed. */ | "expired" /** Revoke or renounce ran against a grant that was already ended. */ - | "already_revoked"; + | "already_revoked" + /** + * v1.37.0 — the invitation named a scope that cannot mean anything: an + * empty array, a key outside the vocabulary, or any scope at all on a + * MANAGE invitation, which is whole-record by construction. + */ + | "invalid_scope"; /** * A refused transition, with a stable code. @@ -146,13 +156,42 @@ export function isGrantActive( return grantState(grant, now) === "ACTIVE"; } +/** + * The three levels, ordered. Higher reaches everything lower. + * + * A total order rather than a set of capabilities per level, because that is + * what the consent screen says out loud: view, view and add, manage. Written + * as two tables rather than as a chain of comparisons so that a fourth level + * cannot be added without deciding where it sits — an unlisted key does not + * typecheck, and the exhaustiveness is the point of the `Record` type. + */ +const ACCESS_RANK: Record = { + READ: 0, + WRITE: 1, + MANAGE: 2, +}; + +const NEED_RANK: Record = { + read: 0, + write: 1, + manage: 2, +}; + /** * Does this grant permit what the caller is about to do? * * Active first, then the level. A READ grant permits reads only; a WRITE grant * permits both, because write access to a record without read access describes - * nothing anyone asked for. The state outranks the level in both directions: a - * revoked WRITE grant permits nothing at all, and no level survives an expiry. + * nothing anyone asked for; a MANAGE grant permits all three, because managing + * a record it cannot read or add to describes nothing either. The state + * outranks the level in both directions: a revoked MANAGE grant permits + * nothing at all, and no level survives an expiry. + * + * The comparison is `>=` on the ranks above and not equality, and the + * difference matters in exactly one direction: a WRITE grant must never + * satisfy `"manage"`. That is the whole of the third level's meaning — an + * accepted WRITE grant carries a consent to add, and nothing about it carries + * a consent to rewrite or remove. */ export function grantAllows( grant: GrantLifecycle & { access: AccountGrantAccess }, @@ -160,7 +199,95 @@ export function grantAllows( now: Date = new Date(), ): boolean { if (!isGrantActive(grant, now)) return false; - return need === "read" ? true : grant.access === "WRITE"; + return ACCESS_RANK[grant.access] >= NEED_RANK[need]; +} + +// ── Scope ─────────────────────────────────────────────────────────────────── + +/** The slice of a grant row the scope predicate reads. */ +export interface GrantScope { + scopeJson: Prisma.JsonValue | null; +} + +/** + * What a stored scope actually opens. + * + * `null` — the whole record. Not a default standing in for a missing answer: + * it is the answer every grant written before the column existed was consented + * as, and the answer an owner who ticks "entire record" gives today. It is a + * first-class value everywhere, never a legacy badge. + * + * A set — those sections and nothing else, `record` included (see + * {@link grantCoversDomain}). + */ +export type ResolvedScope = ReadonlySet | null; + +const EMPTY_SCOPE: ReadonlySet = new Set(); + +/** + * Turn the stored blob into what it opens, refusing anything it cannot read. + * + * Fail-closed, and this is the deliberate inverse of `normalisePrefs` in the + * module gate: that one resolves a malformed preference blob to "everything + * on", because a presentation preference nobody can read should not hide a + * person's own data from them. This one answers an authorization question, so + * a value it cannot read resolves to the EMPTY set — every section refused, + * including any the garbage might have happened to name. Both directions are + * "do the safe thing with a value we do not understand"; they only look + * opposite because the safe thing differs. + * + * What counts as unreadable is deliberately wide: anything that is not an + * array, an empty array, an array holding a key outside the vocabulary, an + * array holding a non-string. There is no partial credit — a set that is half + * recognisable is a set somebody wrote in a shape this file does not + * understand, and honouring the half we recognise would be guessing at + * consent. `record` is not a member of the vocabulary, so a stored `["record"]` + * is unreadable too, which is the correct reading: a scope that means "no + * scope" is a thing the invite path refuses to write, and one that reached + * the column by any other route opens nothing. + */ +export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope { + if (stored === null || stored === undefined) return null; + if (!Array.isArray(stored) || stored.length === 0) return EMPTY_SCOPE; + + const domains = new Set(); + for (const entry of stored) { + if (!isShareDomain(entry)) return EMPTY_SCOPE; + domains.add(entry); + } + return domains; +} + +/** + * Does this grant open the section the route declared? + * + * Three rules, and the second is the one that carries the invariant: + * + * * A NULL scope covers everything, `record` included. That is what every + * grant in the product was until this release and what most will stay. + * * A non-NULL scope NEVER covers `record`. A route that declares `record` + * reads across sections — a health score, a digest, an achievement set — + * and there is no honest way to answer one of those for a delegate who was + * given part of a record. A score that says 70 to the owner and 64 to the + * delegate is a support case and a clinical hazard, so the scoped delegate + * does not get a filtered answer, they get no answer. + * * Otherwise, membership. A key that postdates the invitation is not in the + * stored set and so is not covered, which is why a new section is a + * consent question rather than a schema question. + * + * The level is a separate question, asked by {@link grantAllows}. This one + * knows nothing about READ, WRITE or MANAGE — and MANAGE rows carry a NULL + * scope by construction, so it answers true for them by the first rule rather + * than by a special case. + */ +export function grantCoversDomain( + grant: GrantScope, + domain: ShareScope, +): boolean { + const scope = normaliseScope(grant.scopeJson); + if (scope === null) return true; + if (domain === ENTIRE_RECORD) return false; + return scope.has(domain); } // ── Lifecycle transitions ─────────────────────────────────────────────────── @@ -178,6 +305,22 @@ export interface InviteGrantInput { * how they disagree later. */ access: AccountGrantAccess; + /** + * v1.37.0 — which sections the invitation opens. `null` is the entire + * record, and it is a choice rather than an omission: same no-default + * posture as `access` above, for the same reason. A scope nobody named is a + * scope nobody chose, and two modules that both default it are two modules + * that will disagree about it later. + * + * Refused here, as `invalid_scope`: an empty array (a grant that opens + * nothing is not a grant, it is a mistake wearing one), a key outside the + * vocabulary, and any scope at all beside MANAGE. The request-shape + * validator refuses the same three earlier and more legibly; this is the + * floor under it, because the column's meaning is decided in this file and + * a caller that never went through a Zod schema must not be able to write a + * row the resolver would have to interpret. + */ + scope: ShareDomain[] | null; /** Optional lapse date. Null = the grant runs until somebody ends it. */ expiresAt?: Date | null; } @@ -209,6 +352,17 @@ export async function inviteGrant( throw new GrantError("self_grant"); } + const scope = input.scope; + if (scope !== null) { + // Whole-record by construction, and the refusal is here rather than in a + // comment: "they can do anything, but only to part of you" promises a + // boundary that cannot survive an edit, so the product does not offer it + // and this file does not store it. + if (input.access === "MANAGE") throw new GrantError("invalid_scope"); + if (scope.length === 0) throw new GrantError("invalid_scope"); + if (!scope.every(isShareDomain)) throw new GrantError("invalid_scope"); + } + const now = new Date(); try { return await db.accountGrant.create({ @@ -216,6 +370,17 @@ export async function inviteGrant( grantorId: input.grantorId, granteeId: input.granteeId, access: input.access, + // `DbNull` and not `null`: on a nullable Json column Prisma reads a + // bare `null` as the JSON value `null`, which is a stored blob that + // is not an array and would resolve through `normaliseScope` to the + // empty set — a grant that opens nothing, written by the path meant + // to open everything. The two nulls are one keystroke apart and mean + // opposite things here. + // + // Deduplicated on the way in: a set the owner ticked twice is one + // section, and storing it twice would make two rows with the same + // meaning look different to anything that reads them back. + scopeJson: scope === null ? Prisma.DbNull : [...new Set(scope)], invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts new file mode 100644 index 000000000..9139b7bf0 --- /dev/null +++ b/src/lib/sharing/scope.ts @@ -0,0 +1,92 @@ +/** + * v1.37.0 — the vocabulary a scoped grant is written in. + * + * A grant may open the whole record, or it may open some of it. This file + * holds the closed set of names the second case is allowed to use, and it + * holds nothing else: no predicate, no storage, no route knowledge. The + * predicates live in `grants.ts` beside the rest of what a grant means, and + * the per-route classification lives on the call sites, frozen by + * `src/__tests__/delegable-surface-guard.test.ts`. + * + * Two properties are the whole reason the file exists separately. + * + * * **It is a sharing vocabulary, not the module vocabulary.** There is no + * conversion to `ModuleKey`, no import from `src/lib/modules/`, and there + * never is one. The two masks answer different questions — "did the owner + * turn this part of the product on" and "did the owner open this part of + * their record to that person" — and a route carrying both runs both. The + * day they share a spelling is the day somebody makes one of them imply + * the other, and the implication would run in the dangerous direction: a + * module the owner enabled is not a section the owner shared. + * * **The names are the ones a consent screen can say out loud.** They were + * derived by clustering the delegable read surfaces the product already + * has, not by inventing categories, so each one is a thing a person + * recognises as one thing. Finer splits subjects nobody separates (lab + * results from the analyte list); coarser re-merges the exact split + * somebody asked for. + * + * A stored scope never grows by release. A grant written when this list held + * eight names opens those of the eight it named and nothing added later: the + * set is stored as the keys the owner ticked, and a key that did not exist + * when they ticked cannot be in it. That is why a new domain is a consent + * question rather than a schema question. + */ + +/** + * The sections of a record a grant can be narrowed to. + * + * Order is the consent screen's reading order, not an ordering of weight — + * nothing here ranks above anything else, and no code may treat the index as + * meaning anything. + */ +export const SHARE_DOMAINS = [ + /** Readings: weight, blood pressure, pulse, glucose, sleep, workouts, nutrients, tracked values. */ + "measurements", + /** The medication list, its schedules, doses, side effects and inventory. */ + "medications", + /** Lab results and the biomarker catalogue behind them. */ + "labs", + /** Health background: allergies, family history, anamnesis facts. */ + "profile", + /** Illness episodes and the day logs under them. */ + "illness", + /** Mood entries and mental-health screener history. */ + "mind", + /** Cycle tracking. */ + "cycle", + /** The document vault. */ + "documents", +] as const; + +/** One section of a record. */ +export type ShareDomain = (typeof SHARE_DOMAINS)[number]; + +/** + * The distinguished value for "the entire record", which is what every grant + * written before this release was consented as. + * + * It is deliberately NOT a member of {@link SHARE_DOMAINS}: a scoped grant + * naming it would be a scope that means "no scope", and the one thing a + * narrowing must never be able to express is its own absence. A route + * declaring `record` is a route that reads across sections, and a scoped + * grant never reaches one. + */ +export const ENTIRE_RECORD = "record"; + +/** + * What a delegable route declares it touches: one section, or the record as a + * whole. + */ +export type ShareScope = ShareDomain | typeof ENTIRE_RECORD; + +const DOMAIN_SET: ReadonlySet = new Set(SHARE_DOMAINS); + +/** + * Is this a section name? + * + * The one place an untrusted string becomes a {@link ShareDomain}. `record` + * answers false here on purpose — see {@link ENTIRE_RECORD}. + */ +export function isShareDomain(value: unknown): value is ShareDomain { + return typeof value === "string" && DOMAIN_SET.has(value); +} diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index a1afdb53f..dc7d85bd5 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -100,6 +100,7 @@ async function acceptedGrant() { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); const grant = await acceptGrant({ grantId: invited.id, @@ -123,6 +124,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); expect(invited.acceptedAt).toBeNull(); expect(invited.access).toBe("READ"); @@ -162,6 +164,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "WRITE", + scope: null, }); const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ where: { id: invited.id }, @@ -185,6 +188,7 @@ describe("account grant lifecycle", () => { grantorId: solo.id, granteeId: solo.id, access: "READ", + scope: null, }), ).rejects.toMatchObject({ code: "self_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(0); @@ -199,6 +203,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); await expect( @@ -238,6 +243,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, expiresAt: new Date(Date.now() - 1000), }); @@ -253,6 +259,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, expiresAt: new Date(Date.now() + 60_000), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -373,6 +380,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); await expect( @@ -380,6 +388,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }), ).rejects.toBeInstanceOf(GrantError); await expect( @@ -387,6 +396,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }), ).rejects.toMatchObject({ code: "duplicate_live_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(1); @@ -403,6 +413,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); expect(second.id).not.toBe(first.id); expect(second.acceptedAt).toBeNull(); @@ -436,6 +447,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); await revokeGrant({ grantId: invited.id, grantorId: owner.id }); } @@ -443,6 +455,7 @@ describe("account grant lifecycle", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); expect( await getPrismaClient().accountGrant.count({ @@ -541,6 +554,7 @@ describe("account grant lifecycle paths", () => { grantorId: admin.id, granteeId: live.id, access: "READ", + scope: null, }); const liveGrant = await acceptGrant({ grantId: liveInvite.id, @@ -550,6 +564,7 @@ describe("account grant lifecycle paths", () => { grantorId: admin.id, granteeId: dropped.id, access: "READ", + scope: null, }); await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); const revoked = await revokeGrant({ diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 819afec3f..af149f057 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -130,7 +130,10 @@ async function seedWeight(userId: string, value: number) { */ const delegableRead: (request: NextRequest) => Promise = apiHandler( async () => { - const { user, actor, grantId } = await requireRecordAuth("read"); + const { user, actor, grantId } = await requireRecordAuth( + "read", + "measurements", + ); const rows = await prisma.measurement.findMany({ where: { userId: user.id }, orderBy: { value: "asc" }, @@ -163,6 +166,7 @@ async function household() { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); const grant = await acceptGrant({ grantId: invited.id, @@ -262,6 +266,7 @@ describe("acting-account resolver — the decision is never cached", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, expiresAt: new Date(Date.now() + 400), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -281,6 +286,7 @@ describe("acting-account resolver — the decision is never cached", () => { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); diff --git a/tests/integration/idempotency-record-scoped-cell.test.ts b/tests/integration/idempotency-record-scoped-cell.test.ts index bdb4ec1fc..97684954a 100644 --- a/tests/integration/idempotency-record-scoped-cell.test.ts +++ b/tests/integration/idempotency-record-scoped-cell.test.ts @@ -109,7 +109,7 @@ let handlerRuns = 0; const delegableWrite: (request: NextRequest) => Promise = apiHandler( withIdempotency<[NextRequest]>(async () => { handlerRuns += 1; - const { user } = await requireRecordAuth("write"); + const { user } = await requireRecordAuth("write", "measurements"); const row = await prisma.measurement.create({ data: { userId: user.id, diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index a304b7ab1..8c0a871c2 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -76,7 +76,7 @@ async function signIn(userId: string) { */ const delegableAction: (request: NextRequest) => Promise = apiHandler( async () => { - const { user } = await requireRecordAuth("read"); + const { user } = await requireRecordAuth("read", "measurements"); await auditLog("measurement.read", { userId: user.id, details: { via: "test" }, @@ -99,6 +99,7 @@ async function household() { grantorId: owner.id, granteeId: delegate.id, access: "READ", + scope: null, }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); @@ -162,7 +163,7 @@ describe("who the trail says did it", () => { const handler: (request: NextRequest) => Promise = apiHandler( async () => { - const { actor } = await requireRecordAuth("read"); + const { actor } = await requireRecordAuth("read", "measurements"); await auditLog("delegate.own.act", { userId: actor.id }); return NextResponse.json({ data: { ok: true }, error: null }); }, @@ -225,6 +226,7 @@ describe("delegated reads coalesce to one row a day", () => { grantorId: owner.id, granteeId: second.id, access: "READ", + scope: null, }); await acceptGrant({ grantId: invited.id, granteeId: second.id }); diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts index 493addad9..396be8311 100644 --- a/tests/integration/sharing-delegable-writes.test.ts +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -124,6 +124,7 @@ async function switchInto( grantorId: ownerId, granteeId: delegateId, access, + scope: null, }); const grant = await acceptGrant({ grantId: invited.id, diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 69ce77a6d..90e819089 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -98,6 +98,7 @@ async function switchInto( grantorId: ownerId, granteeId: delegateId, access, + scope: null, }); const grant = await acceptGrant({ grantId: invited.id, diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts index 1ea9748c3..cf49ba564 100644 --- a/tests/integration/sharing-lifecycle.test.ts +++ b/tests/integration/sharing-lifecycle.test.ts @@ -155,7 +155,7 @@ async function switchTo(accountId: string | null): Promise { /** A delegable read, exactly as a migrated route builds one. */ const delegableRead: (request: NextRequest) => Promise = apiHandler( async () => { - const { user, actor } = await requireRecordAuth("read"); + const { user, actor } = await requireRecordAuth("read", "measurements"); const rows = await prisma.measurement.findMany({ where: { userId: user.id }, orderBy: { value: "asc" }, diff --git a/tests/integration/sharing-scope-fence.test.ts b/tests/integration/sharing-scope-fence.test.ts new file mode 100644 index 000000000..1b489b8cb --- /dev/null +++ b/tests/integration/sharing-scope-fence.test.ts @@ -0,0 +1,412 @@ +/** + * v1.37.0 — the section fence, driven as routes against real Postgres. + * + * A scoped grant opens the sections it names and leaves the rest unanswered. + * That sentence is one line of code in the resolver and fifty-seven + * classifications on the call sites, and the classifications are the part a + * machine cannot check: the guard freezes the domain a module declares against + * a literal transcribed from the same table a human read, so a domain assigned + * wrongly in both places agrees with itself perfectly. It would look like + * nothing, anywhere, while a delegate scoped to medications quietly read the + * labs. + * + * So this file drives one representative route per section, in-scope and + * out-of-scope, through the shipped GET exports and the shipped grant + * transitions. Sixteen legs plus the record-wide pair. What that buys is not + * proof that every one of the fifty-seven is right — it is proof that the + * mechanism separates the sections at all, per section, so a wholesale failure + * (the resolver ignoring the scope, the normaliser reading a set as + * permissive, one section's routes classified into another's) cannot be + * silent. The per-module classification stays a review question and says so in + * the guard. + * + * Evidence over verdict, throughout: the in-scope leg asserts the route was + * REACHED, the out-of-scope leg asserts the refusal AND its audit reason. A + * file that only asserted the refusals would pass against a resolver that + * refused everything. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + const user = await getPrismaClient().user.create({ + data: { + username: `scope-${suffix}`, + email: `scope-${suffix}@example.test`, + displayName: `Scope ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + // The cycle probe sits behind its own module gate, which derives from + // the account's gender when the toggle is NULL. Opted in explicitly so + // that a 403 from that route means the section fence and not the module + // gate — two masks, both real, and this file is about one of them. + gender: "FEMALE", + }, + }); + await getPrismaClient().cycleProfile.create({ + data: { userId: user.id, cycleTrackingEnabled: true }, + }); + return user; +} + +async function signIn(userId: string) { + return getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); +} + +/** + * A live, accepted grant with the delegate's browser already inside the + * owner's record. + * + * Minted through the shipped `inviteGrant` / `acceptGrant` rather than by + * writing the row: the scope column's write path is part of what is under + * test, and a row this file wrote itself would keep passing after the invite + * path stopped storing what it was given. + */ +async function switchInto( + ownerId: string, + delegateId: string, + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + scope, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: delegateId, + }); + const session = await signIn(delegateId); + cookieJar.set("healthlog_session", session.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); + return grant; +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +async function drive(handler: Handler, url: string): Promise { + return handler(new NextRequest(`http://localhost${url}`, { method: "GET" }), { + params: Promise.resolve({}), + }); +} + +/** + * The sharing refusals filed for this caller so far. + * + * The resolver writes them fire-and-forget — the 403 is what protects the + * record and an audit write that failed must never turn it into a 500 — so + * the row lands a moment after the response does. Everything here therefore + * settles first. Without the settle the positive legs below would read an + * empty table and agree with themselves, which is the exact failure this + * repository keeps rediscovering: a check that cannot fail. + */ +async function refusals(callerId: string): Promise<{ reason?: string }[]> { + await new Promise((resolve) => setTimeout(resolve, 150)); + const rows = await getPrismaClient().auditLog.findMany({ + where: { userId: callerId, action: "sharing.access.denied" }, + orderBy: { createdAt: "asc" }, + }); + // `details` is stored as a JSON STRING on this table, not a JSON object — + // matching it as an object would compare against a string and fail, or, with + // a looser matcher, pass against anything. + return rows.map((row) => + typeof row.details === "string" + ? (JSON.parse(row.details) as { reason?: string }) + : ((row.details ?? {}) as { reason?: string }), + ); +} + +/** The reason on the most recent one, or null when there is none. */ +async function lastRefusal( + callerId: string, +): Promise<{ reason?: string } | null> { + const rows = await refusals(callerId); + return rows.length === 0 ? null : rows[rows.length - 1]; +} + +/* -------------------------------------------------------------------------- */ +/* One representative route per section */ +/* -------------------------------------------------------------------------- */ + +interface Probe { + url: string; + call: () => Promise; +} + +const PROBES: Record = { + measurements: { + url: "/api/measurements", + call: async () => + drive( + (await import("@/app/api/measurements/route")).GET as Handler, + "/api/measurements", + ), + }, + medications: { + url: "/api/medications", + call: async () => + drive( + (await import("@/app/api/medications/route")).GET as Handler, + "/api/medications", + ), + }, + labs: { + url: "/api/labs", + call: async () => + drive((await import("@/app/api/labs/route")).GET as Handler, "/api/labs"), + }, + profile: { + url: "/api/allergies", + call: async () => + drive( + (await import("@/app/api/allergies/route")).GET as Handler, + "/api/allergies", + ), + }, + illness: { + url: "/api/illness/episodes", + call: async () => + drive( + (await import("@/app/api/illness/episodes/route")).GET as Handler, + "/api/illness/episodes", + ), + }, + mind: { + url: "/api/mood-entries", + call: async () => + drive( + (await import("@/app/api/mood-entries/route")).GET as Handler, + "/api/mood-entries", + ), + }, + cycle: { + url: "/api/cycle/cycles", + call: async () => + drive( + (await import("@/app/api/cycle/cycles/route")).GET as Handler, + "/api/cycle/cycles", + ), + }, + documents: { + url: "/api/documents/inbound", + call: async () => + drive( + (await import("@/app/api/documents/inbound/route")).GET as Handler, + "/api/documents/inbound", + ), + }, + record: { + url: "/api/dashboard/widgets", + call: async () => + drive( + (await import("@/app/api/dashboard/widgets/route")).GET as Handler, + "/api/dashboard/widgets", + ), + }, +}; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("the probe set covers the vocabulary", () => { + it("has one route per section, plus the record-wide case", () => { + // The non-zero half. Every leg below iterates this table, so a table that + // lost a section would silently stop testing it — and a section added to + // the production enum without a probe would go untested from birth. + expect(SHARE_DOMAINS.length).toBeGreaterThan(0); + expect(Object.keys(PROBES).sort()).toEqual( + [...SHARE_DOMAINS, "record"].sort(), + ); + }); +}); + +describe("a scoped grant opens the sections it names", () => { + it.each(SHARE_DOMAINS)("%s answers when the grant names it", async (open) => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, [open]); + + const response = await PROBES[open].call(); + + // Reached, not merely "not 403": a route that answered 500 would satisfy + // an inequality against 403 and prove nothing about the fence. + expect(response.status, PROBES[open].url).toBe(200); + expect(await lastRefusal(delegate.id)).toBeNull(); + }); + + it.each(SHARE_DOMAINS)( + "%s is the only section that answers", + async (open) => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, [open]); + + const closed = [...SHARE_DOMAINS, "record" as const].filter( + (d) => d !== open, + ); + // Non-zero: the loop below must actually run. + expect(closed.length).toBeGreaterThan(0); + + for (const domain of closed) { + const response = await PROBES[domain].call(); + expect(response.status, PROBES[domain].url).toBe(403); + const body = await response.json(); + expect(body.meta?.errorCode, PROBES[domain].url).toBe( + "sharing.access.denied", + ); + // The reason is the audit row's, never the caller's. If the section + // ever reaches the wire this assertion is the one that has to move, + // which is the point of asserting it here rather than on the body. + expect( + await lastRefusal(delegate.id), + PROBES[domain].url, + ).toMatchObject({ reason: "out_of_scope" }); + } + }, + ); +}); + +describe("a NULL scope still opens the whole record", () => { + it.each([...SHARE_DOMAINS, "record" as const])( + "%s answers exactly as it did before scoping existed", + async (domain) => { + // The no-regression leg. Every grant in the product today is this one, + // and the release is byte-identical for all of them. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, null); + + const response = await PROBES[domain].call(); + expect(response.status, PROBES[domain].url).toBe(200); + expect(await lastRefusal(delegate.id)).toBeNull(); + }, + ); +}); + +describe("an unreadable scope opens nothing", () => { + it("refuses every section, including one the garbage names", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await switchInto(owner.id, delegate.id, ["medications"]); + + // Planted directly, because the invite path refuses to write it — which is + // the point: the read side has to hold on its own, against a value that + // reached the column by a route this application does not have (a restore + // from an older shape, a hand-edited row, a future writer with a bug). + await getPrismaClient().$executeRawUnsafe( + `UPDATE account_grants SET scope_json = '"medications"'::jsonb WHERE id = $1`, + grant.id, + ); + + for (const domain of [...SHARE_DOMAINS, "record" as const]) { + const response = await PROBES[domain].call(); + expect(response.status, PROBES[domain].url).toBe(403); + expect(await lastRefusal(delegate.id), PROBES[domain].url).toMatchObject({ + reason: "out_of_scope", + }); + } + }); + + it("refuses even the section a well-formed set would have opened", async () => { + // The sharpest form of the same claim, and the one that separates + // fail-closed from fail-open: the string spells a real section name. A + // normaliser that reached for anything it recognised inside a value it + // could not parse would let this through. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await switchInto(owner.id, delegate.id, ["medications"]); + + // Evidence first: the grant works before the column is corrupted, so the + // refusal below is the corruption and not the fixture. + expect((await PROBES.medications.call()).status).toBe(200); + + await getPrismaClient().$executeRawUnsafe( + `UPDATE account_grants SET scope_json = '["medications", "not-a-section"]'::jsonb WHERE id = $1`, + grant.id, + ); + + expect((await PROBES.medications.call()).status).toBe(403); + expect(await lastRefusal(delegate.id)).toMatchObject({ + reason: "out_of_scope", + }); + }); +}); + +describe("the refusal does not distinguish itself", () => { + it("says the same bytes as a caller who was granted nothing", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id, ["medications"]); + const outOfScope = await PROBES.labs.call(); + const outOfScopeBody = await outOfScope.text(); + + // A stranger naming the same record. Different story, same answer. + const stranger = await makeUser("stranger"); + const session = await signIn(stranger.id); + cookieJar.set("healthlog_session", session.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + const noGrant = await PROBES.labs.call(); + + expect(outOfScope.status).toBe(noGrant.status); + expect(outOfScopeBody).toBe(await noGrant.text()); + expect(outOfScopeBody).not.toContain("scope"); + + // …and the operator's trail tells them apart, which is the half that has + // to keep working while the wire stays silent. + expect(await lastRefusal(stranger.id)).toMatchObject({ + reason: "no_active_grant", + }); + }); +});