From d86358f3e423bcff3451580d8ae67ae2a1917301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 5 Aug 2026 22:55:41 +0200 Subject: [PATCH] fix: recover managed sharing boundaries --- ....test.ts => sharing-surface-guard.test.ts} | 1176 ++++++++++++++++- src/app/api/allergies/[id]/route.ts | 90 +- src/app/api/allergies/route.ts | 9 +- .../biomarkers/[id]/__tests__/route.test.ts | 4 + src/app/api/biomarkers/[id]/route.ts | 56 +- .../cycle/__tests__/external-id-floor.test.ts | 3 + src/app/api/cycle/cycles/[id]/route.ts | 7 +- src/app/api/cycle/day-logs/[id]/route.ts | 28 +- src/app/api/cycle/day-logs/route.ts | 37 +- src/app/api/cycle/period/route.ts | 85 +- src/app/api/cycle/symptoms/custom/route.ts | 12 +- src/app/api/dashboard/summary/route.ts | 6 +- src/app/api/export/health-record/route.ts | 17 +- src/app/api/family-history/[id]/route.ts | 37 +- src/app/api/family-history/route.ts | 6 +- .../illness/episodes/[id]/day-logs/route.ts | 21 +- .../illness/episodes/[id]/resolve/route.ts | 6 +- .../illness/episodes/[id]/restore/route.ts | 6 +- src/app/api/illness/episodes/[id]/route.ts | 41 +- .../insights/biomarker-assessment/route.ts | 7 +- .../insights/blood-pressure-status/route.ts | 7 +- src/app/api/insights/bmi-status/route.ts | 7 +- .../api/insights/breathing-screening/route.ts | 6 +- src/app/api/insights/cards/route.ts | 6 +- src/app/api/insights/coach-read/route.ts | 6 +- src/app/api/insights/comprehensive/route.ts | 10 +- src/app/api/insights/correlations/route.ts | 6 +- src/app/api/insights/derived/batch/route.ts | 15 +- src/app/api/insights/derived/route.ts | 7 +- src/app/api/insights/ecg/[id]/route.ts | 6 +- src/app/api/insights/ecg/route.ts | 8 +- src/app/api/insights/glp1-plateau/route.ts | 6 +- src/app/api/insights/glp1-timeline/route.ts | 6 +- src/app/api/insights/health-status/route.ts | 6 +- src/app/api/insights/labs-changes/route.ts | 6 +- .../medication-compliance-status/route.ts | 7 +- src/app/api/insights/metric-status/route.ts | 7 +- src/app/api/insights/mood-status/route.ts | 7 +- src/app/api/insights/narrative/route.ts | 23 +- src/app/api/insights/patterns/[id]/route.ts | 8 +- src/app/api/insights/patterns/route.ts | 6 +- src/app/api/insights/pulse-status/route.ts | 7 +- src/app/api/insights/pulse/intraday/route.ts | 6 +- src/app/api/insights/rhythm-events/route.ts | 6 +- src/app/api/insights/targets/route.ts | 6 +- src/app/api/insights/weight-status/route.ts | 7 +- src/app/api/labs/[id]/route.ts | 62 +- src/app/api/labs/restore/route.ts | 13 +- .../[id]/__tests__/route.test.ts | 8 + .../[id]/complete/__tests__/route.test.ts | 8 + .../[id]/complete/route.ts | 6 +- .../api/measurement-reminders/[id]/route.ts | 84 +- .../[id]/satisfy/route.ts | 6 +- src/app/api/measurement-reminders/route.ts | 28 +- .../measurements/[id]/__tests__/route.test.ts | 24 +- src/app/api/measurements/[id]/route.ts | 56 +- src/app/api/measurements/bulk-delete/route.ts | 13 +- src/app/api/measurements/restore/route.ts | 12 +- src/app/api/medications/[id]/glp1/route.ts | 13 +- .../intake/[eventId]/__tests__/route.test.ts | 19 +- .../[id]/intake/[eventId]/route.ts | 70 +- .../[id]/intake/bulk-delete/route.ts | 13 +- .../intake/import/__tests__/route.test.ts | 20 +- .../medications/[id]/intake/import/route.ts | 40 +- .../medications/[id]/intake/purge/route.ts | 7 +- .../[id]/inventory/[itemId]/route.ts | 61 +- .../api/medications/[id]/inventory/route.ts | 17 +- src/app/api/medications/[id]/route.ts | 85 +- .../schedule-revisions/[revisionId]/route.ts | 99 +- .../[id]/schedule-revisions/route.ts | 9 +- .../[id]/side-effects/[logId]/route.ts | 7 +- .../intake/dose-history-import/route.ts | 13 +- .../api/mental-health/assessments/route.ts | 22 +- .../mood-entries/[id]/__tests__/route.test.ts | 19 +- src/app/api/mood-entries/[id]/route.ts | 54 +- .../api/mood-entries/__tests__/route.test.ts | 16 +- src/app/api/mood-entries/bulk-delete/route.ts | 12 +- src/app/api/mood-entries/restore/route.ts | 12 +- src/app/api/mood-entries/route.ts | 46 +- .../nutrients/water/__tests__/route.test.ts | 8 + src/app/api/nutrients/water/route.ts | 36 +- .../delegated-write-affordances.test.tsx | 2 +- src/hooks/use-record-capabilities.ts | 2 +- src/lib/api-handler.ts | 12 +- src/lib/illness/day-log-write.ts | 35 +- .../__tests__/status-cache-delegated.test.ts | 109 ++ src/lib/insights/status-cache.ts | 25 + src/lib/logging/event-builder.ts | 15 + src/lib/logging/types.ts | 10 + src/lib/medications/scheduling/slot-upsert.ts | 9 +- src/lib/sharing/audit-details.ts | 127 ++ src/lib/sharing/delegated-generation.ts | 34 + src/lib/sharing/scope.ts | 2 +- .../sharing-delegable-routes.test.ts | 2 +- .../sharing-delegable-writes.test.ts | 2 +- .../sharing-manage-perimeter.test.ts | 719 ++++++++++ 96 files changed, 3578 insertions(+), 397 deletions(-) rename src/__tests__/{delegable-surface-guard.test.ts => sharing-surface-guard.test.ts} (60%) create mode 100644 src/lib/insights/__tests__/status-cache-delegated.test.ts create mode 100644 src/lib/sharing/audit-details.ts create mode 100644 src/lib/sharing/delegated-generation.ts create mode 100644 tests/integration/sharing-manage-perimeter.test.ts diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts similarity index 60% rename from src/__tests__/delegable-surface-guard.test.ts rename to src/__tests__/sharing-surface-guard.test.ts index 26b85acf9..8cc24b41d 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1,5 +1,5 @@ /** - * Structural guards on the delegable, guardian and actor-surface allowlists. + * Structural guards on the sharing, guardian and actor-surface allowlists. * * `requireRecordAuth` is a declaration: this route may act on somebody else's * health record, at a named level and over a named section of it. @@ -74,6 +74,8 @@ import { describe, expect, it } from "vitest"; import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareScope } from "@/lib/sharing/scope"; +import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix"; + const SRC = join(process.cwd(), "src"); /** The delegable declaration. Frozen by `DELEGABLE_ROUTES`. */ @@ -129,15 +131,21 @@ const COOKIE_ONLY_HELPERS = [ /** * Floors, not counts. The tree today holds 447 route modules (440 of them under - * `app/api/`) and 2,338 source files, of which 346 route modules resolve auth + * `app/api/`) and 2,338 source files, of which 236 route modules resolve auth * the ordinary way. These numbers exist so that a scan which collapses to a * handful of files — a changed enumeration, a moved `src`, a `cwd` that is not * the repo root — fails instead of agreeing with an empty allowlist. They sit * far below today's figures so ordinary churn never touches them. + * + * v1.37.0 — the bare-`requireAuth` floor came down from 250 to 200 because + * that population SHRANK by seventy modules in one release: the MANAGE + * perimeter moved them onto the record resolver. The floor is a "did the scan + * happen" tripwire, not a budget, and leaving it above the real figure would + * have made it fire on the change it was supposed to survive. */ const ROUTE_MODULE_FLOOR = 300; const SOURCE_FILE_FLOOR = 1500; -const BARE_REQUIRE_AUTH_FLOOR = 250; +const BARE_REQUIRE_AUTH_FLOOR = 200; /* -------------------------------------------------------------------------- */ /* The file set */ @@ -411,6 +419,254 @@ function callsSymbol(rel: string, symbol: string): boolean { return callSites(rel).some((c) => c.name === symbol); } +/** + * v1.37.0 — the exported HTTP verbs whose bodies reach the record resolver at + * a given level. + * + * The membership matchers above answer "does this MODULE manage", which is the + * right question for the frozen list and the wrong one for two conditions the + * classification attached to VERBS. A module can hold a delegable read arm and + * a MANAGE mutation, and "does the module call `auditLog`" is a question about + * the file while "does the destructive arm file a row" is a question about the + * handler. This resolves the export each call sits under, so the legs below + * can ask the second one. + * + * Same alias resolution as `callSitesIn`: an aliased or namespace-qualified + * import comes back under its original name. The verb names are the Next.js + * route contract, so the set is closed and a helper function that happens to + * call the resolver is not mistaken for a handler. + */ +const HTTP_VERB_EXPORTS = new Set([ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", +]); + +/** The non-safe verbs: the ones that can destroy or rewrite. */ +const MUTATING_VERBS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +function resolverVerbsIn(rel: string, need: string): string[] { + if (!read(rel).includes(RECORD_RESOLVER)) return []; + const source = read(rel); + const parsed = ts.createSourceFile( + rel, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + const aliases = new Map(); + const namespaces = new Set(); + parsed.forEachChild((node) => { + if (!ts.isImportDeclaration(node)) return; + const bindings = node.importClause?.namedBindings; + if (!bindings) return; + if (ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text); + return; + } + for (const spec of bindings.elements) { + aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text); + } + }); + + // Module-level functions the handlers delegate to. Several routes export + // `apiHandler(withIdempotency(postThing))` and resolve the record inside + // `postThing`, so a walk that stopped at the export's own subtree would + // report "declares nothing" for a route that plainly declares something. + const localFunctions = new Map(); + parsed.forEachChild((node) => { + if (ts.isFunctionDeclaration(node) && node.name) { + localFunctions.set(node.name.text, node); + } + }); + + const declares = (subtree: ts.Node, seen = new Set()): boolean => { + let found = false; + const walk = (node: ts.Node): void => { + if (found) return; + if (ts.isIdentifier(node) && localFunctions.has(node.text)) { + if (!seen.has(node.text)) { + seen.add(node.text); + if (declares(localFunctions.get(node.text) as ts.Node, seen)) { + found = true; + return; + } + } + } + if (ts.isCallExpression(node)) { + const callee = node.expression; + let name: string | null = null; + if (ts.isIdentifier(callee)) { + name = aliases.get(callee.text) ?? callee.text; + } else if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + namespaces.has(callee.expression.text) + ) { + name = callee.name.text; + } + if ( + name === RECORD_RESOLVER && + literalArg(node.arguments[0]) === need + ) { + found = true; + return; + } + } + node.forEachChild(walk); + }; + subtree.forEachChild(walk); + return found; + }; + + const verbs: string[] = []; + parsed.forEachChild((node) => { + if (ts.isVariableStatement(node)) { + const exported = node.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!exported) return; + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue; + if (!HTTP_VERB_EXPORTS.has(decl.name.text)) continue; + if (declares(decl)) verbs.push(decl.name.text); + } + return; + } + if (ts.isFunctionDeclaration(node) && node.name) { + const exported = node.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!exported || !HTTP_VERB_EXPORTS.has(node.name.text)) return; + if (declares(node)) verbs.push(node.name.text); + } + }); + return verbs.sort(); +} + +/** + * The keys of every `details` object literal this module passes to + * `auditLog()`. + * + * Read structurally, so the keys the reviewer can see at the call site are the + * ones the assertion is made against. Nested literals count — a conditional + * spread is the shape a route uses when a fact only exists on one branch, and + * the alternative (filing the key with a null on every request) is worse than + * the widening: absence should read as absence on the wire. + */ +function auditDetailKeysIn(rel: string): string[][] { + if (!read(rel).includes("auditLog")) return []; + const parsed = ts.createSourceFile( + rel, + read(rel), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const found: string[][] = []; + const walk = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "auditLog" && + node.arguments[1] !== undefined && + ts.isObjectLiteralExpression(node.arguments[1]) + ) { + for (const prop of node.arguments[1].properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === "details" && + ts.isObjectLiteralExpression(prop.initializer) + ) { + const keys: string[] = []; + const collect = (inner: ts.Node): void => { + if (ts.isPropertyAssignment(inner) && ts.isIdentifier(inner.name)) { + keys.push(inner.name.text); + } + if (ts.isShorthandPropertyAssignment(inner)) { + keys.push(inner.name.text); + } + inner.forEachChild(collect); + }; + collect(prop.initializer); + found.push(keys); + } + } + } + node.forEachChild(walk); + }; + parsed.forEachChild(walk); + return found; +} + +/** The bucket-key expressions this module passes to a rate limiter. */ +const RATE_LIMITERS = new Set(["checkRateLimit", "checkAuthSurfaceRateLimit"]); + +function rateLimitKeysIn(rel: string): string[] { + const source = read(rel); + if (![...RATE_LIMITERS].some((name) => source.includes(name))) return []; + const parsed = ts.createSourceFile( + rel, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const keys: string[] = []; + const walk = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + RATE_LIMITERS.has(node.expression.text) && + node.arguments[0] !== undefined + ) { + keys.push(node.arguments[0].getText(parsed)); + } + node.forEachChild(walk); + }; + parsed.forEachChild(walk); + return keys; +} + +/** + * v1.37.0 — the two MANAGE members whose reconstruction is real and is not + * shaped like C3's helper. Frozen with their reason lines, for the same reason + * every other exception in this file is: an unnamed exception is a hole, and a + * named one is a decision somebody has to re-read to widen. + * + * The values are the audit-detail keys that DO the reconstructing, asserted by + * the leg below, so an exception cannot survive the deletion of the thing it + * excuses. + */ +const MANAGE_RECONSTRUCTION_BY_HAND: Record< + string, + { keys: readonly string[]; why: string } +> = { + "app/api/medications/[id]/side-effects/[logId]/route.ts": { + keys: ["entry", "severity", "occurredAt"], + why: "A hard delete whose audit row already reconstructed the destroyed thing before this release — the only place the tree did §7.5 correctly on its own. It stays as it is rather than being re-routed through the C3 helper, because the helper would add nothing and the one defect here is the opposite one: `entry` is encrypted on the row and is held in the audit row as plaintext. That duplication is pre-existing, is named in the classification, and is not made worse by admitting the verb.", + }, + "app/api/illness/episodes/[id]/day-logs/route.ts": { + keys: ["date", "previousSymptoms", "revived"], + why: "C3 in this domain's shape, and the shape is why the helper does not fit: nothing is deleted. The upsert's UPDATE branch forces `deletedAt: null` — reviving a day the owner removed — and replaces that day's symptom links wholesale. What has to survive is therefore the date, the symptom set that was there, and the revival, none of which is a destroyed ROW with an id.", + }, +}; + +/** The MANAGE modules carrying a given condition tag. */ +function manageModulesWith(tag: ConditionTag): string[] { + return Object.entries(DELEGABLE_MANAGE_ROUTES) + .filter(([, entry]) => entry.conditions.includes(tag)) + .map(([rel]) => rel) + .sort(); +} + /** The `GrantNeed` literals this module passes to the record resolver. */ function recordNeeds(rel: string): Set { if (!read(rel).includes(RECORD_RESOLVER)) return new Set(); @@ -510,6 +766,35 @@ interface DelegableEntry { why: string; } +/** + * v1.37.0 — the conditions a MANAGE admission was granted on. + * + * The classification pass admitted several verbs only WITH a named condition: + * an actor-keyed rate bucket, an audit row that says what was destroyed, a + * suppressed generation enqueue. A tag here is not a note — it is the half of + * the admission that lives in code, and the legs below check the code carries + * it. A verb whose condition is not implemented is a verb that was refused. + * + * * **C1** the rate bucket keys on the ACTOR. + * * **C2** actor-facing strings resolve to the ACTOR's locale. + * * **C3** a hard delete files what it destroyed: model, id, label, date. + * * **C4** an overwrite files the field family and the previous scalars. + * * **C5** the delegated path enqueues no generation. + * * **C6** the delegated arm refuses the sync client's `externalId`. + * * **C7** a schedule replacement files the cadence and the slots it took. + * * **C8** a re-created row keeps the original's provenance. + * * **C9** work that outlives the request is joined by id to an + * actor-stamped audit row. + */ +type ConditionTag = + "C1" | "C2" | "C3" | "C4" | "C5" | "C6" | "C7" | "C8" | "C9"; + +/** One frozen MANAGE admission: its section, its conditions, and its reason. */ +interface ManageEntry extends DelegableEntry { + /** The conditions this admission was granted on. Empty is a real answer. */ + conditions: readonly ConditionTag[]; +} + /** * Route modules permitted to act on a record that may not be the caller's. * @@ -771,6 +1056,222 @@ const DELEGABLE_ROUTES: Record = { 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.", }, + + /* ------------------------------------------------------------------ */ + /* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */ + /* */ + /* They were not reachable on a shared record at any level before this */ + /* release, and they are not reachable at READ or WRITE now: every one */ + /* of them declares `"manage"`. They carry an entry here because leg */ + /* (a) freezes the set of modules naming the resolver at all, and */ + /* because leg (f) needs the section to check the call sites against — */ + /* the admission argument itself lives in the manage literal below. */ + /* ------------------------------------------------------------------ */ + + "app/api/measurements/bulk-delete/route.ts": { + domain: "measurements", + why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/measurements/restore/route.ts": { + domain: "measurements", + why: "Undoing a deletion, which is a management act once the manager holds the delete. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/measurement-reminders/[id]/complete/route.ts": { + domain: "measurements", + why: "Marking a reminder satisfied. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/measurement-reminders/[id]/satisfy/route.ts": { + domain: "measurements", + why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/labs/restore/route.ts": { + domain: "labs", + why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/illness/episodes/[id]/resolve/route.ts": { + domain: "illness", + why: "Closing an episode. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/illness/episodes/[id]/restore/route.ts": { + domain: "illness", + why: "Reopening a deleted episode with its day logs intact. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/mood-entries/bulk-delete/route.ts": { + domain: "mind", + why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/mood-entries/restore/route.ts": { + domain: "mind", + why: "Undoing it. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/cycle/cycles/[id]/route.ts": { + domain: "cycle", + why: "Deleting one cycle; soft, audited, tombstoned to sync. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/cycle/day-logs/[id]/route.ts": { + domain: "cycle", + why: "Editing and deleting one day; the delete soft-deletes. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/cycle/period/route.ts": { + domain: "cycle", + why: "Setting a period boundary, which re-anchors the neighbouring cycles. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": { + domain: "medications", + why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/inventory/[itemId]/route.ts": { + domain: "medications", + why: "Editing and deleting a stock item; the delete is hard and already carries its final state, so C3 adds the label and expiry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/side-effects/[logId]/route.ts": { + domain: "medications", + why: "Removing a side-effect record. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/glp1/route.ts": { + domain: "medications", + why: "Recording a titration step. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/intake/[eventId]/route.ts": { + domain: "medications", + why: "Correcting and deleting a dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/intake/bulk-delete/route.ts": { + domain: "medications", + why: "Tombstoning a run of doses. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/intake/purge/route.ts": { + domain: "medications", + why: "Clearing a medication's intake history. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/[id]/intake/import/route.ts": { + domain: "medications", + why: "Importing a dose history for one medication. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/medications/intake/dose-history-import/route.ts": { + domain: "medications", + why: "The whole-regimen form of the same import, same worker, same properties. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/nutrients/water/route.ts": { + domain: "measurements", + why: "Logging hydration. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/biomarker-assessment/route.ts": { + domain: "record", + why: "Reading the generated biomarker assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/blood-pressure-status/route.ts": { + domain: "record", + why: "The generated blood-pressure assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/bmi-status/route.ts": { + domain: "record", + why: "The generated BMI assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/medication-compliance-status/route.ts": { + domain: "record", + why: "The generated compliance assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/metric-status/route.ts": { + domain: "record", + why: "The generic per-metric assessment, and the widest of them. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/mood-status/route.ts": { + domain: "record", + why: "The generated mood assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/pulse-status/route.ts": { + domain: "record", + why: "The generated pulse assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/weight-status/route.ts": { + domain: "record", + why: "The generated weight assessment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/narrative/route.ts": { + domain: "record", + why: "The period narrative. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/derived/route.ts": { + domain: "record", + why: "The derived-score assessment, AI-warmed. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/derived/batch/route.ts": { + domain: "record", + why: "The deterministic batch form of the same read, no provider on this one. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/breathing-screening/route.ts": { + domain: "record", + why: "A deterministic screening read over the record's own data. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/cards/route.ts": { + domain: "record", + why: "The alert cards, from a rule engine rather than a provider. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/coach-read/route.ts": { + domain: "record", + why: "The two server-authoritative lines above a metric chart. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/comprehensive/route.ts": { + domain: "record", + why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/correlations/route.ts": { + domain: "record", + why: "Correlations over the record's own series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/glp1-plateau/route.ts": { + domain: "record", + why: "A deterministic plateau read. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/glp1-timeline/route.ts": { + domain: "record", + why: "The titration timeline. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/health-status/route.ts": { + domain: "record", + why: "The composite status read. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/labs-changes/route.ts": { + domain: "record", + why: "What moved in the record's labs. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/patterns/route.ts": { + domain: "record", + why: "The record's correlation patterns. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/patterns/[id]/route.ts": { + domain: "record", + why: "Dismissing one pattern. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/pulse/intraday/route.ts": { + domain: "record", + why: "The intraday pulse series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/rhythm-events/route.ts": { + domain: "record", + why: "Rhythm events over the record's own recordings. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/targets/route.ts": { + domain: "record", + why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/ecg/route.ts": { + domain: "record", + why: "Listing the record's ECG recordings. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/insights/ecg/[id]/route.ts": { + domain: "record", + why: "One ECG recording of the record. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/dashboard/summary/route.ts": { + domain: "record", + why: "The record's summary strip, beside the snapshot that is already delegable. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/export/health-record/route.ts": { + domain: "record", + why: "The doctor report, a guardian in the paediatrician's office, and the one export surface an invited manager reaches. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, }; /** @@ -856,7 +1357,358 @@ const DELEGABLE_WRITE_ROUTES: Record = { * 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 = {}; +const DELEGABLE_MANAGE_ROUTES: Record = { + "app/api/measurements/[id]/route.ts": { + domain: "measurements", + conditions: ["C4"], + why: "Editing and deleting a reading. The delete tombstones and the audit row already names the reading; the edit needs C4 because a value overwritten with no before-image cannot be read back out of the feed.", + }, + "app/api/measurements/bulk-delete/route.ts": { + domain: "measurements", + conditions: ["C1"], + why: "Tombstoning a selection. Reconstruction rides the rows, not the details; C1 so a manager burns their own bucket.", + }, + "app/api/measurements/restore/route.ts": { + domain: "measurements", + conditions: ["C1"], + why: "Undoing a deletion, which is a management act once the manager holds the delete. C1.", + }, + "app/api/measurement-reminders/route.ts": { + domain: "measurements", + conditions: [], + why: "Arming a preventive-care reminder. It rings the record's own phone, which is the person the reminder is about.", + }, + "app/api/measurement-reminders/[id]/route.ts": { + domain: "measurements", + conditions: ["C3", "C4"], + why: "Editing and deleting one reminder. The delete is hard and its details are an id, so C3; the edit takes C4.", + }, + "app/api/measurement-reminders/[id]/complete/route.ts": { + domain: "measurements", + conditions: [], + why: "Marking a reminder satisfied. Additive management of a schedule the level admits.", + }, + "app/api/measurement-reminders/[id]/satisfy/route.ts": { + domain: "measurements", + conditions: [], + why: "The same primitive from the other caller.", + }, + "app/api/labs/[id]/route.ts": { + domain: "labs", + conditions: ["C4"], + why: "Editing and deleting a lab result. The delete soft-deletes with a restore route beside it; the edit takes C4.", + }, + "app/api/labs/restore/route.ts": { + domain: "labs", + conditions: ["C1"], + why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. C1.", + }, + "app/api/biomarkers/[id]/route.ts": { + domain: "labs", + conditions: ["C4"], + why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it stays refused.", + }, + "app/api/allergies/route.ts": { + domain: "profile", + conditions: [], + why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.", + }, + "app/api/allergies/[id]/route.ts": { + domain: "profile", + conditions: ["C3", "C4"], + why: "Correcting and removing an allergy. The delete is hard, so C3 carries the allergen, severity and date and never the encrypted reaction text; the edit takes C4.", + }, + "app/api/family-history/route.ts": { + domain: "profile", + conditions: [], + why: "Recording a relative's condition. Third-party data by design, admitted on the same reasoning the read arm was.", + }, + "app/api/family-history/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one entry; the delete soft-deletes. C4 on the edit.", + }, + "app/api/illness/episodes/[id]/route.ts": { + domain: "illness", + conditions: ["C4"], + why: "Editing and deleting an episode; the delete soft-deletes. C4 on the edit.", + }, + "app/api/illness/episodes/[id]/resolve/route.ts": { + domain: "illness", + conditions: [], + why: "Closing an episode.", + }, + "app/api/illness/episodes/[id]/restore/route.ts": { + domain: "illness", + conditions: [], + why: "Reopening a deleted episode with its day logs intact.", + }, + "app/api/illness/episodes/[id]/day-logs/route.ts": { + domain: "illness", + conditions: ["C3"], + why: "Writing a day of an episode. The upsert revives a tombstoned day and replaces that day's symptom links, so C3 names the date, the previous symptom set, and the revival.", + }, + "app/api/mood-entries/route.ts": { + domain: "mind", + conditions: ["C6"], + why: "Recording a mood observation. C6: the delegated arm refuses `externalId`, which is the sync client's upsert handle and not a person's.", + }, + "app/api/mood-entries/[id]/route.ts": { + domain: "mind", + conditions: ["C4"], + why: "Editing and deleting an entry; the delete soft-deletes. C4 on the edit.", + }, + "app/api/mood-entries/bulk-delete/route.ts": { + domain: "mind", + conditions: ["C1"], + why: "Tombstoning a selection. C1.", + }, + "app/api/mood-entries/restore/route.ts": { + domain: "mind", + conditions: ["C1"], + why: "Undoing it. C1.", + }, + "app/api/mental-health/assessments/route.ts": { + domain: "mind", + conditions: ["C1", "C2"], + why: "Recording a screener administered by the manager. C1, and C2 because the crisis-resource copy must be in the language of the person holding the phone.", + }, + "app/api/cycle/cycles/[id]/route.ts": { + domain: "cycle", + conditions: [], + why: "Deleting one cycle; soft, audited, tombstoned to sync.", + }, + "app/api/cycle/day-logs/route.ts": { + domain: "cycle", + conditions: ["C4"], + why: "Writing a day of the cycle log. The upsert replaces the day, so C4 names the date and the fields replaced.", + }, + "app/api/cycle/day-logs/[id]/route.ts": { + domain: "cycle", + conditions: ["C4"], + why: "Editing and deleting one day; the delete soft-deletes. C4 on the edit.", + }, + "app/api/cycle/period/route.ts": { + domain: "cycle", + conditions: ["C4"], + why: "Setting a period boundary, which re-anchors the neighbouring cycles. C4 on the dates it moves.", + }, + "app/api/cycle/symptoms/custom/route.ts": { + domain: "cycle", + conditions: ["C1"], + why: "Adding to the record's own symptom vocabulary. C1.", + }, + "app/api/medications/[id]/route.ts": { + domain: "medications", + conditions: ["C4", "C7"], + why: "Replacing a medication and its schedule. C4, and C7 because the previous cadence and the tombstoned pending slots are the only unrecoverable part. The DELETE beside it stays refused.", + }, + "app/api/medications/[id]/schedule-revisions/route.ts": { + domain: "medications", + conditions: [], + why: "Archiving a schedule era the compliance engine reads.", + }, + "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": { + domain: "medications", + conditions: ["C3", "C4"], + why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. C4 on the edit.", + }, + "app/api/medications/[id]/inventory/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Recording stock the low-stock notification reads. C1.", + }, + "app/api/medications/[id]/inventory/[itemId]/route.ts": { + domain: "medications", + conditions: ["C3", "C4"], + why: "Editing and deleting a stock item; the delete is hard and already carries its final state, so C3 adds the label and expiry. C4 on the edit.", + }, + "app/api/medications/[id]/side-effects/[logId]/route.ts": { + domain: "medications", + conditions: [], + why: "Removing a side-effect record. The one hard delete in the tree whose details already reconstruct.", + }, + "app/api/medications/[id]/glp1/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Recording a titration step. C1.", + }, + "app/api/medications/[id]/intake/[eventId]/route.ts": { + domain: "medications", + conditions: ["C4", "C8"], + why: "Correcting and deleting a dose. The correction tombstones and re-creates, so C8 preserves the original's source and C4 names what changed; the delete soft-deletes.", + }, + "app/api/medications/[id]/intake/bulk-delete/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Tombstoning a run of doses. C1.", + }, + "app/api/medications/[id]/intake/purge/route.ts": { + domain: "medications", + conditions: [], + why: "Clearing a medication's intake history. Tombstones since the delegated-write release and drops only recomputable rollups.", + }, + "app/api/medications/[id]/intake/import/route.ts": { + domain: "medications", + conditions: ["C1", "C9"], + why: "Importing a dose history for one medication. Additive with duplicates skipped and honest provenance. C1, C9.", + }, + "app/api/medications/intake/dose-history-import/route.ts": { + domain: "medications", + conditions: ["C1", "C9"], + why: "The whole-regimen form of the same import, same worker, same properties. C1, C9.", + }, + "app/api/nutrients/water/route.ts": { + domain: "measurements", + conditions: ["C1", "C4"], + why: "Logging hydration. It overwrites a day total with no per-entry ledger, so C4 carries the previous total; C1 on the bucket.", + }, + "app/api/insights/biomarker-assessment/route.ts": { + domain: "record", + conditions: ["C5"], + why: "Reading the generated biomarker assessment. C5: a cache miss must not enqueue generation on a delegated path.", + }, + "app/api/insights/blood-pressure-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated blood-pressure assessment. C5.", + }, + "app/api/insights/bmi-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated BMI assessment. C5.", + }, + "app/api/insights/medication-compliance-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated compliance assessment. C5.", + }, + "app/api/insights/metric-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generic per-metric assessment, and the widest of them. C5.", + }, + "app/api/insights/mood-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated mood assessment. C5.", + }, + "app/api/insights/pulse-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated pulse assessment. C5.", + }, + "app/api/insights/weight-status/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The generated weight assessment. C5.", + }, + "app/api/insights/narrative/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The period narrative. It warms unconditionally rather than on a miss, so C5 matters more here than anywhere.", + }, + "app/api/insights/derived/route.ts": { + domain: "record", + conditions: ["C5"], + why: "The derived-score assessment, AI-warmed. C5.", + }, + "app/api/insights/derived/batch/route.ts": { + domain: "record", + conditions: ["C1"], + why: "The deterministic batch form of the same read, no provider on this one. C1.", + }, + "app/api/insights/breathing-screening/route.ts": { + domain: "record", + conditions: [], + why: "A deterministic screening read over the record's own data.", + }, + "app/api/insights/cards/route.ts": { + domain: "record", + conditions: [], + why: "The alert cards, from a rule engine rather than a provider.", + }, + "app/api/insights/coach-read/route.ts": { + domain: "record", + conditions: [], + why: "The two server-authoritative lines above a metric chart. Pure compute, no provider, no cache table.", + }, + "app/api/insights/comprehensive/route.ts": { + domain: "record", + conditions: [], + why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path.", + }, + "app/api/insights/correlations/route.ts": { + domain: "record", + conditions: [], + why: "Correlations over the record's own series. No LLM, no cache table.", + }, + "app/api/insights/glp1-plateau/route.ts": { + domain: "record", + conditions: [], + why: "A deterministic plateau read.", + }, + "app/api/insights/glp1-timeline/route.ts": { + domain: "record", + conditions: [], + why: "The titration timeline.", + }, + "app/api/insights/health-status/route.ts": { + domain: "record", + conditions: [], + why: "The composite status read.", + }, + "app/api/insights/labs-changes/route.ts": { + domain: "record", + conditions: [], + why: "What moved in the record's labs.", + }, + "app/api/insights/patterns/route.ts": { + domain: "record", + conditions: [], + why: "The record's correlation patterns.", + }, + "app/api/insights/patterns/[id]/route.ts": { + domain: "record", + conditions: [], + why: "Dismissing one pattern. The dismissal stores its own evidence hash and effect size, so it reverses and reads back.", + }, + "app/api/insights/pulse/intraday/route.ts": { + domain: "record", + conditions: [], + why: "The intraday pulse series.", + }, + "app/api/insights/rhythm-events/route.ts": { + domain: "record", + conditions: [], + why: "Rhythm events over the record's own recordings.", + }, + "app/api/insights/targets/route.ts": { + domain: "record", + conditions: [], + why: "The record's resolved targets.", + }, + "app/api/insights/ecg/route.ts": { + domain: "record", + conditions: [], + why: "Listing the record's ECG recordings. The POST arm beside it is a device ingest and stays refused.", + }, + "app/api/insights/ecg/[id]/route.ts": { + domain: "record", + conditions: [], + why: "One ECG recording of the record.", + }, + "app/api/dashboard/summary/route.ts": { + domain: "record", + conditions: [], + why: "The record's summary strip, beside the snapshot that is already delegable.", + }, + "app/api/export/health-record/route.ts": { + domain: "record", + conditions: ["C1"], + why: "The doctor report, a guardian in the paediatrician's office, and the one export surface an invited manager reaches. C1 moves the shared export bucket to the actor.", + }, +}; /** * v1.37.0 — route modules permitted to administer a MANAGED PROFILE: a record @@ -908,12 +1760,14 @@ const ACTOR_ROUTES: Record = { * 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. + * v1.37.0 — 69 → 190 as the MANAGE perimeter lands: 51 modules join the + * delegable list (108), and the manage literal fills with 70. The guardian + * literal is still empty, so the number moves again when it fills. It is one + * number rather than one per list on purpose: filling either of the new + * literals 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; +const FROZEN_ENTRY_COUNT = 190; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -1514,21 +2368,31 @@ describe("(e) the delegable write set is frozen", () => { ); }); - it("leaves the rest of the delegable set reading only", () => { + it("leaves the rest of the delegable set off the write level", () => { // The non-zero half of the leg above, and the one that makes an empty - // matcher fail: the read-only remainder must be found, must be large, and - // must be found by the SAME matcher that decided the write set. If - // `recordNeeds` stopped returning anything, this drops to zero and fails - // rather than agreeing that nothing writes. - const readOnly = Object.keys(DELEGABLE_ROUTES).filter( + // matcher fail: the remainder must be found, must be large, and must be + // found by the SAME matcher that decided the write set. If `recordNeeds` + // stopped returning anything, this drops to zero and fails rather than + // agreeing that nothing writes. + // + // v1.37.0 — the remainder is no longer "read-only". A module can now be + // delegable because it declares `"manage"` and nothing else: the fifty-one + // that joined this list in this release are exactly that, and demanding a + // `"read"` from them would be demanding they open at a level the + // classification refused. What every non-write member must still be is + // NOT a write: `"write"` is the one level whose members are frozen + // separately, and a module that grew a write arm without joining that + // literal is the diff this leg exists to catch. + const remainder = Object.keys(DELEGABLE_ROUTES).filter( (rel) => !(rel in DELEGABLE_WRITE_ROUTES), ); - expect(readOnly.length).toBeGreaterThan(20); - for (const rel of readOnly) { + expect(remainder.length).toBeGreaterThan(20); + for (const rel of remainder) { const needs = recordNeeds(rel); - expect(needs.has("read"), `${rel} does not resolve a record read`).toBe( - true, - ); + expect( + needs.has("read") || needs.has("manage"), + `${rel} resolves no record level at all`, + ).toBe(true); expect(needs.has("write"), `${rel} writes without being listed`).toBe( false, ); @@ -1634,10 +2498,9 @@ describe("(g) the MANAGE route set is frozen", () => { 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 + // The evidence the matcher works, kept from when this literal was empty: + // the left-hand side below is produced by the SAME `recordNeeds` that + // produces a large non-empty read set two legs up, 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); @@ -1646,6 +2509,45 @@ describe("(g) the MANAGE route set is frozen", () => { expect(managing).toEqual(Object.keys(DELEGABLE_MANAGE_ROUTES).sort()); }); + it("declares MANAGE on a verb handler, not somewhere in the file", () => { + // The per-verb resolution the conditions below need, and its own non-zero + // evidence: every frozen member must resolve at least one exported handler + // declaring `"manage"`. A matcher that stopped finding handlers fails on + // the first member rather than agreeing that nothing mutates. + let total = 0; + for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) { + const verbs = resolverVerbsIn(rel, "manage"); + expect( + verbs.length, + `${rel} declares manage outside a handler`, + ).toBeGreaterThan(0); + total += verbs.length; + } + expect(total).toBeGreaterThan(Object.keys(DELEGABLE_MANAGE_ROUTES).length); + }); + + it("keeps the admitted mutation inventory complete and discoverable", () => { + expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(62); + + const expected = ADMITTED_MUTATING_HANDLERS.map( + ({ handlerModule, action, level }) => + `${handlerModule}:${action}:${level}`, + ).sort(); + const discovered = Object.keys(DELEGABLE_ROUTES) + .flatMap((rel) => + ["write", "manage"].flatMap((need) => + resolverVerbsIn(rel, need) + .filter((verb) => MUTATING_VERBS.has(verb)) + .map((verb) => `${rel}:${verb}:${need}`), + ), + ) + .sort(); + + expect(discovered.length).toBeGreaterThan(0); + expect(discovered).toEqual(expected); + }); + 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 @@ -1655,12 +2557,25 @@ describe("(g) the MANAGE route set is frozen", () => { } }); - it("every manage module files its rows through auditLog", () => { + it("every mutating 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)) { + // + // Scoped to the modules whose MANAGE arm is a mutation, which the write + // leg did not have to say because every member of that literal was one. + // Twenty-seven members here are GETs — the generated and derived reads, + // which open at MANAGE because they read the record whole, not because + // they change it. Demanding an audit row from a read would either be + // unsatisfiable or satisfied by writing a row per navigation, and the + // second is worse than the first. + const mutating = Object.keys(DELEGABLE_MANAGE_ROUTES).filter((rel) => + resolverVerbsIn(rel, "manage").some((verb) => MUTATING_VERBS.has(verb)), + ); + expect(mutating.length).toBeGreaterThan(30); + + for (const rel of mutating) { expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( true, ); @@ -1675,6 +2590,215 @@ describe("(g) the MANAGE route set is frozen", () => { ).toEqual([]); } }); + + it("every destructive manage arm is reconstructable or tombstoned", () => { + // The rule the level ships under, in the part a machine can check: a verb + // that removes rows either leaves them (a tombstone the owner can restore + // or the sync feed can carry) or files what it destroyed. A module that + // does neither is the failure this leg exists to catch — the hard delete + // whose audit row is an id pointing at nothing. + const destructive = Object.keys(DELEGABLE_MANAGE_ROUTES).filter((rel) => + resolverVerbsIn(rel, "manage").includes("DELETE"), + ); + expect(destructive.length).toBeGreaterThan(10); + + for (const rel of destructive) { + const entry = DELEGABLE_MANAGE_ROUTES[rel]; + const tombstones = identifiers(rel).has("deletedAt"); + expect( + tombstones || + entry.conditions.includes("C3") || + rel in MANAGE_RECONSTRUCTION_BY_HAND, + `${rel} destroys rows without a tombstone and without C3`, + ).toBe(true); + } + }); + + it("the hand-shaped reconstructions still name what they carry", () => { + // The exception map's own leg. Each member claims specific audit-detail + // keys do the reconstructing; if those keys leave the handler the excuse + // goes with them, and this fails rather than the module quietly becoming + // an id-only delete under a reason line that says otherwise. + const members = Object.entries(MANAGE_RECONSTRUCTION_BY_HAND); + expect(members.length).toBeGreaterThan(0); + + for (const [rel, { keys, why }] of members) { + expect(why.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + expect(DELEGABLE_MANAGE_ROUTES, rel).toHaveProperty([rel]); + const carried = auditDetailKeysIn(rel); + expect(carried.length, `${rel} files no audit details`).toBeGreaterThan( + 0, + ); + const satisfied = carried.some((names) => + keys.every((key) => names.includes(key)), + ); + expect(satisfied, `${rel} no longer files ${keys.join(", ")}`).toBe(true); + } + }); + + it("C3 modules name what they destroyed", () => { + // The condition as code: a hard delete files the model, the id, a human + // label and the date, through the shared helper — and the `details` + // literal at the call site still names the row's own id, so the reviewer + // reading the handler sees what the row is about without opening the + // helper. + const members = manageModulesWith("C3"); + expect(members.length).toBeGreaterThan(0); + + for (const rel of members) { + if (rel in MANAGE_RECONSTRUCTION_BY_HAND) continue; + expect( + callsSymbol(rel, "destroyedDetails"), + `${rel} carries C3 and never calls destroyedDetails`, + ).toBe(true); + const named = auditDetailKeysIn(rel).some((keys) => + keys.some((key) => /(^id$|Id$)/.test(key)), + ); + expect(named, `${rel} files no id on any audit details literal`).toBe( + true, + ); + } + }); + + it("C4 modules name what they overwrote", () => { + const members = manageModulesWith("C4"); + expect(members.length).toBeGreaterThan(10); + + for (const rel of members) { + expect( + callsSymbol(rel, "overwriteDetails"), + `${rel} carries C4 and never calls overwriteDetails`, + ).toBe(true); + } + }); + + it("C1 modules burn the actor's rate allowance, never the record's", () => { + // The frozen precedent from `medications/compliance`, asserted on the + // bucket key itself rather than on the presence of `actor` anywhere in + // the file. Both halves matter: the key must name the actor, and no key + // in the module may still name the record — a module that keys one bucket + // correctly and leaves a second on `user.id` lets a manager lock the + // owner out through the one nobody looked at. + const members = manageModulesWith("C1"); + expect(members.length).toBeGreaterThan(10); + + for (const rel of members) { + const keys = rateLimitKeysIn(rel); + expect( + keys.length, + `${rel} carries C1 and rate-limits nothing`, + ).toBeGreaterThan(0); + for (const key of keys) { + expect(key, `${rel} keys a bucket on the record`).toContain("actor.id"); + expect(key, `${rel} keys a bucket on the record`).not.toContain( + "user.id", + ); + } + } + }); + + it("C5 is enforced at the enqueue, not at the ten call sites", () => { + // The condition that costs the owner money if it is wrong, and the one + // place it is implemented. Ten frozen entries carry C5; nine of them + // reach the provider through `resolveReadOnlyStatusMiss` and the tenth + // (the narrative) enqueues in its own handler. Asserting the choke point + // rather than the ten modules is deliberate — a generator added later + // inherits the suppression without knowing it exists — so the anchor has + // to be exactly here. + expect(manageModulesWith("C5").length).toBe(10); + + const miss = functionSource( + "lib/insights/status-cache.ts", + "resolveReadOnlyStatusMiss", + ); + expect(miss.length).toBeGreaterThan(0); + expect(miss).toContain("delegatedGenerationSuppressed"); + + expect( + callsSymbol( + "app/api/insights/narrative/route.ts", + "delegatedGenerationSuppressed", + ), + ).toBe(true); + + // …and the fact it reads has to be stamped. The resolver is where the + // owner row is in hand, so it is the only place that can decide it. + const resolver = functionSource( + "lib/api-handler.ts", + "resolveSwitchedRecord", + ); + expect(resolver.length).toBeGreaterThan(0); + expect(resolver).toContain("setDelegatedGenerationSuppressed"); + }); + + it("the remaining conditions are carried where they were argued", () => { + // C2, C6, C7, C8 and C9 are one or two modules each, so they are asserted + // by name rather than by a family rule. Each expectation is the shape of + // the condition, not a spelling of it: the locale the screener resolves, + // the refusal the mood create carries, the two facts a schedule + // replacement files, the provenance a moved dose keeps, and the job id + // that joins an import to the person who started it. + const screener = "app/api/mental-health/assessments/route.ts"; + expect(manageModulesWith("C2")).toEqual([screener]); + expect(read(screener)).toContain("actor.locale"); + + const mood = "app/api/mood-entries/route.ts"; + expect(manageModulesWith("C6")).toEqual([mood]); + expect(read(mood)).toContain("mood.create.external_id_not_delegable"); + + const medication = "app/api/medications/[id]/route.ts"; + expect(manageModulesWith("C7")).toEqual([medication]); + const cadence = auditDetailKeysIn(medication).some( + (keys) => + keys.includes("replacedCadence") && + keys.includes("tombstonedSlotCount"), + ); + expect(cadence, "the schedule replacement files no cadence").toBe(true); + + const dose = "app/api/medications/[id]/intake/[eventId]/route.ts"; + expect(manageModulesWith("C8")).toEqual([dose]); + // The hardcoded provenance this condition removed. A re-created row that + // stamps `WEB` says a human typed a dose that arrived from a device. + expect(read(dose)).not.toContain('createSource: "WEB"'); + + expect(manageModulesWith("C9")).toEqual([ + "app/api/medications/[id]/intake/import/route.ts", + "app/api/medications/intake/dose-history-import/route.ts", + ]); + for (const rel of manageModulesWith("C9")) { + const joined = auditDetailKeysIn(rel).some((keys) => + keys.includes("jobId"), + ); + expect(joined, `${rel} enqueues work no audit row names`).toBe(true); + } + }); + + it("every frozen condition tag is one the file defines", () => { + // The membership assertion the domain column already has, for the other + // review-sensitive column. A tag nobody checks is a condition nobody + // implemented, so the tag set and the legs above have to move together. + const checked: ConditionTag[] = [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + ]; + let tagged = 0; + for (const [rel, entry] of Object.entries(DELEGABLE_MANAGE_ROUTES)) { + for (const tag of entry.conditions) { + expect(checked, `${rel}: ${tag}`).toContain(tag); + tagged += 1; + } + } + // Non-zero evidence: the tags are the admission, so an empty condition + // column across seventy entries is a transcription that dropped them. + expect(tagged).toBeGreaterThan(40); + }); }); /* -------------------------------------------------------------------------- */ diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index 6276c3dba..d95fe144f 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -12,9 +12,13 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -49,12 +53,26 @@ export const GET = apiHandler( export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting an allergy entry, with the replaced fields + // on the audit row. + const { user } = await requireRecordAuth("manage", "profile"); const { id } = await params; + // The pre-image the audit row needs (C4). Encrypted columns stay out of + // the select: the feed names them and never quotes them. const existing = await prisma.allergy.findUnique({ where: { id }, - select: { id: true, userId: true, deletedAt: true }, + select: { + id: true, + userId: true, + deletedAt: true, + substance: true, + category: true, + type: true, + severity: true, + status: true, + onsetAt: true, + }, }); if ( !existing || @@ -101,7 +119,34 @@ export const PATCH = apiHandler( await auditLog("allergy.update", { userId: user.id, ipAddress: getClientIp(request), - details: { allergyId: id }, + // C4 — the replaced fields. The reaction and the note are named and + // never quoted; both are encrypted at rest and the audit table is not a + // second store for them. + details: { + allergyId: id, + ...overwriteDetails({ + before: { + substance: existing.substance, + category: existing.category, + type: existing.type, + severity: existing.severity, + status: existing.status, + onsetAt: existing.onsetAt, + }, + after: { + substance: updated.substance, + category: updated.category, + type: updated.type, + severity: updated.severity, + status: updated.status, + onsetAt: updated.onsetAt, + }, + redacted: [ + ...(entry.reaction !== undefined ? ["reaction"] : []), + ...(entry.note !== undefined ? ["note"] : []), + ], + }), + }, }); annotate({ @@ -114,12 +159,27 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and a hard delete: the row goes and the audit row is + // what is left. C3 below is the condition this admission rests on. + const { user } = await requireRecordAuth("manage", "profile"); const { id } = await params; + // Widened for the audit row (C3): what was destroyed, in plaintext columns + // only. `reactionEncrypted` and `notesEncrypted` are deliberately absent — + // lifting a decrypted reaction into the audit table would give it a second + // retention story and no rotation. const existing = await prisma.allergy.findUnique({ where: { id }, - select: { id: true, userId: true }, + select: { + id: true, + userId: true, + substance: true, + category: true, + type: true, + severity: true, + status: true, + onsetAt: true, + }, }); if (!existing || existing.userId !== user.id) { return apiError("Allergy not found", 404); @@ -136,7 +196,23 @@ export const DELETE = apiHandler( await auditLog("allergy.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { allergyId: id }, + // C3 — the allergen, its severity and its recorded date, and never the + // encrypted reaction text. + details: { + allergyId: id, + ...destroyedDetails({ + model: "Allergy", + id, + label: existing.substance, + effectiveAt: existing.onsetAt, + extra: { + category: existing.category, + type: existing.type, + severity: existing.severity, + status: existing.status, + }, + }), + }, }); annotate({ diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index c51e799e7..4617cdcd6 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -11,7 +11,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -91,13 +91,16 @@ async function postAllergy(request: NextRequest): Promise { // and family history have exactly one home today, that home is a personal // account surface a switch rightly closes, and bolting a second copy onto a // shared page would split one concept across two places. Re-admitting is - // one line here plus one entry in `delegable-surface-guard.test.ts` plus the + // one line here plus one entry in `sharing-surface-guard.test.ts` plus the // paragraph that argues it — which is exactly the reviewed diff that guard // exists to force. // // The half that was always the point is untouched: a caregiver can still // READ the allergy list inside the record. - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Recording an allergy for somebody whose record you + // manage is the case the level exists for; on a managed profile it is the + // guardian's ordinary act. Additive, audited, and the row is the record's. + const { user } = await requireRecordAuth("manage", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts index bb3f0a75d..9a7cb416d 100644 --- a/src/app/api/biomarkers/[id]/__tests__/route.test.ts +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -38,6 +38,7 @@ vi.mock("next/headers", () => ({ import { PUT, DELETE } from "../route"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; const SESSION_OK = { @@ -78,6 +79,9 @@ function delReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.37.0 — the 422 breadcrumb goes through `auditLog()` and the route + // chains `.catch()` on it; `resetAllMocks` clears the factory's resolution. + vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(prisma.$transaction).mockImplementation((async ( ops: Promise[], ) => Promise.all(ops)) as never); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 37aa32673..45cc9f98a 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { @@ -83,7 +84,12 @@ export const GET = apiHandler( export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and the closest call in this domain: a reference range + // recolours every reading under it, so this is the one admitted edit whose + // error is silent rather than visible. C4 on the audit row is what puts it + // in the owner's feed. The DELETE below stays refused — it takes the + // readings with it and files an id. + const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; const existing = await prisma.biomarker.findFirst({ where: { id } }); @@ -107,17 +113,16 @@ export const PUT = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "labs.biomarker.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, biomarkerId: id }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.biomarker.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, biomarkerId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "labs.biomarker.update.invalid", }); @@ -175,7 +180,32 @@ export const PUT = apiHandler( await auditLog("biomarker.update", { userId: user.id, ipAddress: getClientIp(request), - details: { biomarkerId: id }, + // C4, and this is the edit that needs it most: a reference range + // recolours every reading under it, so the error is silent. The feed has + // to show the range that was there before. The context note is named and + // never quoted; it is encrypted on the row. + details: { + biomarkerId: id, + ...overwriteDetails({ + before: { + name: existing.name, + unit: existing.unit, + lowerBound: existing.lowerBound, + upperBound: existing.upperBound, + panel: existing.panel, + hidden: existing.hidden, + }, + after: { + name: updated.name, + unit: updated.unit, + lowerBound: updated.lowerBound, + upperBound: updated.upperBound, + panel: updated.panel, + hidden: updated.hidden, + }, + redacted: d.context !== undefined ? ["context"] : [], + }), + }, }); annotate({ diff --git a/src/app/api/cycle/__tests__/external-id-floor.test.ts b/src/app/api/cycle/__tests__/external-id-floor.test.ts index 9d313003b..3b28effc5 100644 --- a/src/app/api/cycle/__tests__/external-id-floor.test.ts +++ b/src/app/api/cycle/__tests__/external-id-floor.test.ts @@ -196,8 +196,11 @@ describe("POST /api/cycle/period — external-id stability floor", () => { }); it("accepts the real HealthKit id and reaches the write transaction", async () => { + // v1.37.0 — the boundary transaction reports the anchors it moved so the + // audit row can name them (C4); an empty set is the no-neighbour case. vi.mocked(prisma.$transaction).mockResolvedValue({ cycleId: "cycle-1", + moved: {}, } as never); const res = await postPeriod( req("/api/cycle/period", { diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 2810bc90e..642d5a81c 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -6,7 +6,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiError, getClientIp } from "@/lib/api-response"; @@ -16,7 +16,10 @@ type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft, audited, and it emits a sync tombstone, so the + // cycle is reconstructable from its own row. The whole-domain eraser + // (`DELETE /api/cycle/all`) stays the owner's at every level. + const { user } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 2d72868e0..7cd6fb3b8 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -11,9 +11,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -32,7 +33,8 @@ type RouteParams = { params: Promise<{ id: string }> }; export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Editing one day of the cycle log. + const { user } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -44,6 +46,8 @@ export const PATCH = apiHandler( select: { id: true, userId: true, + // v1.37.0 — the day the audit row names (C4). + date: true, sexualActivity: true, protectedSex: true, pregnancyTest: true, @@ -156,7 +160,21 @@ export const PATCH = apiHandler( await auditLog("cycle.day-log.update", { userId: user.id, ipAddress: getClientIp(request), - details: { dayLogId: id }, + // C4 in this domain's shape: the date and the fields replaced, named + // and never valued. See the sibling create route for why the values + // stay off the audit table in this domain. + details: { + dayLogId: id, + date: existing.date, + ...overwriteDetails({ + before: {}, + after: {}, + redacted: Object.entries(body) + .filter(([, value]) => value !== undefined) + .map(([key]) => key) + .sort(), + }), + }, }); annotate({ @@ -173,7 +191,9 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft delete: the row stays and the sync feed carries + // it as a tombstone. + const { user } = await requireRecordAuth("manage", "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 fb9a4a480..4b1f8cbda 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -14,9 +14,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -75,8 +76,24 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(row ? toCycleDayLogDTO(row) : null); }); +/** + * The day-log fields a write actually carried, for the audit row. Names only: + * every one of these is either intimate or encrypted at rest, and the audit + * table is not a second store for either. + */ +function replacedDayLogFields(entry: Record): string[] { + const carried = ["date", "source", "externalId", "loggedAt"]; + return Object.entries(entry) + .filter(([key, value]) => value !== undefined && !carried.includes(key)) + .map(([key]) => key) + .sort(); +} + async function postDayLog(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means + // here; the gate below resolves `user.gender` against the RECORD, so the + // module follows the record rather than the caller. + const { user } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -139,7 +156,21 @@ async function postDayLog(request: NextRequest): Promise { await auditLog("cycle.day-log.upsert", { userId: user.id, ipAddress: getClientIp(request), - details: { dayLogId: result.id, existed: result.existed }, + // C4 in this domain's shape: the date and the fields the write replaced, + // named and never valued. This is the product's most sensitive domain and + // §7.5's "no full-row snapshots" is at its sharpest here — a feed line + // saying which day was rewritten is the whole requirement, and the values + // behind it stay on the row where they are already encrypted. + details: { + dayLogId: result.id, + existed: result.existed, + date: entry.date, + ...overwriteDetails({ + before: {}, + after: {}, + redacted: result.existed ? replacedDayLogFields(entry) : [], + }), + }, }); annotate({ diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index fedfa6f61..8806cdc65 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -12,9 +12,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, getClientIp, @@ -40,8 +41,28 @@ import { addDays, dayDiff } from "@/lib/cycle/day-math"; export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod)); +/** + * v1.37.0 — the anchors a boundary write moves, carried out of the + * transaction so the audit row can name them (C4). + */ +interface MovedAnchors { + priorCycleId?: string; + /** Day keys, the shape this domain stores its dates in. */ + priorEndDateBefore?: string | null; + priorEndDateAfter?: string | null; + priorLengthBefore?: number | null; + priorLengthAfter?: number | null; + openedEndDateBefore?: string | null; + openedEndDateAfter?: string | null; + periodEndDateBefore?: string | null; + periodEndDateAfter?: string | null; +} + async function postPeriod(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles, + // which is the destructive part of it: a handful of dates move and nothing + // else records what they were. The audit row below carries them (C4). + const { user } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -81,9 +102,23 @@ async function postPeriod(request: NextRequest): Promise { const prior = await db.menstrualCycle.findFirst({ where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, orderBy: { startDate: "desc" }, - select: { id: true, startDate: true }, + // v1.37.0 — `endDate` and `lengthDays` are read for the audit row: + // this write overwrites both on the prior cycle and they are the + // dates C4 exists to name. + select: { + id: true, + startDate: true, + endDate: true, + lengthDays: true, + }, }); + const moved: MovedAnchors = {}; if (prior) { + moved.priorCycleId = prior.id; + moved.priorEndDateBefore = prior.endDate; + moved.priorEndDateAfter = addDays(date, -1); + moved.priorLengthBefore = prior.lengthDays; + moved.priorLengthAfter = dayDiff(date, prior.startDate); await db.menstrualCycle.update({ where: { id: prior.id }, data: { @@ -117,6 +152,8 @@ async function postPeriod(request: NextRequest): Promise { select: { startDate: true }, }); if (next) { + moved.openedEndDateBefore = cycle.endDate; + moved.openedEndDateAfter = addDays(next.startDate, -1); await db.menstrualCycle.update({ where: { id: cycle.id }, data: { @@ -126,23 +163,30 @@ async function postPeriod(request: NextRequest): Promise { }, }); } - return { cycleId: cycle.id as string | null }; + return { cycleId: cycle.id as string | null, moved }; } // `end`: stamp the current cycle's periodEndDate. const current = await db.menstrualCycle.findFirst({ where: { userId: user.id, deletedAt: null, startDate: { lte: date } }, orderBy: { startDate: "desc" }, - select: { id: true }, + // v1.37.0 — `periodEndDate` for the audit row: this arm overwrites it. + select: { id: true, periodEndDate: true }, }); if (!current) { - return { cycleId: null }; + return { cycleId: null, moved: {} satisfies MovedAnchors }; } await db.menstrualCycle.update({ where: { id: current.id }, data: { periodEndDate: date, syncVersion: { increment: 1 } }, }); - return { cycleId: current.id as string | null }; + return { + cycleId: current.id as string | null, + moved: { + periodEndDateBefore: current.periodEndDate, + periodEndDateAfter: date, + } satisfies MovedAnchors, + }; }); if (txResult.cycleId === null) { @@ -188,7 +232,32 @@ async function postPeriod(request: NextRequest): Promise { await auditLog("cycle.period.boundary", { userId: user.id, ipAddress: getClientIp(request), - details: { action, date, cycleId }, + // C4 — the dates this boundary moved on the neighbouring cycles. They are + // the only unrecoverable part of the write: the day log it upserts is a + // row, the re-anchoring is an overwrite of two columns on somebody else's + // cycle history. + details: { + action, + date, + cycleId, + ...(txResult.moved.priorCycleId + ? { priorCycleId: txResult.moved.priorCycleId } + : {}), + ...overwriteDetails({ + before: { + priorEndDate: txResult.moved.priorEndDateBefore, + priorLengthDays: txResult.moved.priorLengthBefore, + openedEndDate: txResult.moved.openedEndDateBefore, + periodEndDate: txResult.moved.periodEndDateBefore, + }, + after: { + priorEndDate: txResult.moved.priorEndDateAfter, + priorLengthDays: txResult.moved.priorLengthAfter, + openedEndDate: txResult.moved.openedEndDateAfter, + periodEndDate: txResult.moved.periodEndDateAfter, + }, + }), + }, }); annotate({ diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 7c66a5c8a..798a4a7e3 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -24,7 +24,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -68,15 +68,21 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log + // writes the level admits need in order to say anything. + const { user, actor } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; // Cap mutation rate so a single session can't flood the encrypted-label // catalogue (each create runs an encrypt + audit write). + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `cycle:symptom:custom:${user.id}`, + `cycle:symptom:custom:${actor.id}`, 30, 60_000, ); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 5dd8a4195..02970705e 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -51,7 +51,7 @@ * map the shared snapshot builder uses (`disabledSummaryTypes`); the filter * runs OUTSIDE the cache because a module toggle does not evict it. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -347,7 +347,9 @@ interface ActivityDayRow { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); annotate({ action: { name: "dashboard.summary" } }); // v1.4.25 W7b — anchor every day-bucket call to the user's display diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index bf5a48e39..a9b7bfb14 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -7,8 +7,9 @@ * - `fhir` → `application/fhir+json` (HL7 FHIR R4 document Bundle) * - `package` → `application/zip` (PDF + Bundle + README) * - * Auth: cookie session OR Bearer token (`requireAuth`). - * Rate-limit: shared `export:` bucket (10/h). + * Auth: cookie session OR Bearer token, resolved through `requireRecordAuth` + * at MANAGE — the report is reachable on a record the caller manages. + * Rate-limit: shared `export:` bucket (10/h). * Audit: `health-record.export` — format, window, and the chosen leaf ids, * never the values behind them. * @@ -21,7 +22,7 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -84,7 +85,10 @@ async function rememberChoices( } export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE: the doctor report is the one export an invited manager + // reaches, because it renders a prepared artefact from a declared selection + // rather than handing over the record whole. + const { user, actor } = await requireRecordAuth("manage", "record"); annotate({ action: { name: "export.health-record.build" } }); // The whole doctor-report surface is the `doctorReport` module. Refuse with @@ -93,7 +97,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(user.id, "doctorReport"); if (!gate.enabled) return gate.response; - const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); + // v1.37.0 — the shared export bucket keys on the ACTOR. A manager burns + // their own hourly allowance rather than the owner's, and switching records + // does not hand them a fresh one. + const rl = await checkRateLimit(`export:${actor.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { return apiError("Maximum 10 exports per hour", 429); } diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts index 047c70863..2343fe733 100644 --- a/src/app/api/family-history/[id]/route.ts +++ b/src/app/api/family-history/[id]/route.ts @@ -10,9 +10,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -51,12 +52,21 @@ export const GET = apiHandler( export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting one relative's entry. + const { user } = await requireRecordAuth("manage", "profile"); const { id } = await params; + // Widened for the audit row (C4); the encrypted note stays out. const existing = await prisma.familyHistoryEntry.findUnique({ where: { id }, - select: { id: true, userId: true, deletedAt: true }, + select: { + id: true, + userId: true, + deletedAt: true, + relationship: true, + condition: true, + ageAtOnset: true, + }, }); if ( !existing || @@ -98,7 +108,23 @@ export const PATCH = apiHandler( await auditLog("family-history.update", { userId: user.id, ipAddress: getClientIp(request), - details: { entryId: id }, + // C4 — the replaced fields; the note is named and never quoted. + details: { + entryId: id, + ...overwriteDetails({ + before: { + relationship: existing.relationship, + condition: existing.condition, + ageAtOnset: existing.ageAtOnset, + }, + after: { + relationship: updated.relationship, + condition: updated.condition, + ageAtOnset: updated.ageAtOnset, + }, + redacted: entry.note !== undefined ? ["note"] : [], + }), + }, }); annotate({ @@ -115,7 +141,8 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft delete: the row stays and reconstructs itself. + const { user } = await requireRecordAuth("manage", "profile"); const { id } = await params; const existing = await prisma.familyHistoryEntry.findUnique({ diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index caffb79a7..3bf1435df 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -12,7 +12,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -77,7 +77,9 @@ async function postFamilyHistory(request: NextRequest): Promise { // record. The classification admitted it and named the discomfort. Landing // it together with the surface that offers it means the copy on that surface // can say whose statement the row is, which no route comment can. - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Third-party data by design, admitted on the same + // reasoning the read arm was: it is the record's own health background. + const { user } = await requireRecordAuth("manage", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, 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 d735a5722..9d01977df 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -138,7 +138,11 @@ export const GET = apiHandler( export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and this one is an edit wearing a create's verb: the + // upsert revives a tombstoned day and replaces that day's symptom links. + // Admitted at the level that admits editing, on the condition that the + // audit row says what the write replaced. + const { user } = await requireRecordAuth("manage", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; @@ -169,7 +173,18 @@ export const POST = apiHandler( await auditLog("illness.day-log.upsert", { userId: user.id, ipAddress: getClientIp(request), - details: { dayLogId: result.id, episodeId: id, existed: result.existed }, + // C3 in this domain's shape: the date, the symptom set that was there + // before, and whether the write brought back a day the owner deleted. + // Without those three the feed says "updated a day log" about a day + // somebody deliberately removed. + details: { + dayLogId: result.id, + episodeId: id, + existed: result.existed, + date: parsed.data.date, + previousSymptoms: result.replaced.previousSymptomKeys, + revived: result.replaced.revived, + }, }); annotate({ diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index f835587ad..f1a77e14c 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -10,7 +10,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -28,7 +28,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Closing an episode; the window it writes is the + // record's own and the reopen is one route over. + const { user } = await requireRecordAuth("manage", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/illness/episodes/[id]/restore/route.ts b/src/app/api/illness/episodes/[id]/restore/route.ts index ce17f9773..082aafb19 100644 --- a/src/app/api/illness/episodes/[id]/restore/route.ts +++ b/src/app/api/illness/episodes/[id]/restore/route.ts @@ -13,7 +13,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; @@ -25,7 +25,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( withIdempotency<[NextRequest, RouteParams]>(async (request, { params }) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Reopening a deleted episode with its day logs + // intact; the undo beside a delete the level admits. + const { user } = await requireRecordAuth("manage", "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 a9165a279..102c23314 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -10,9 +10,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -55,7 +56,10 @@ export const GET = apiHandler( export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The module gate below resolves against the RECORD, so + // a manager cannot edit an episode in a record whose owner switched the + // module off. + const { user } = await requireRecordAuth("manage", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; @@ -69,6 +73,11 @@ export const PATCH = apiHandler( deletedAt: true, onsetAt: true, resolvedAt: true, + // v1.37.0 — the pre-image the audit row carries (C4). + label: true, + type: true, + lifecycle: true, + parentConditionId: true, }, }); if ( @@ -150,7 +159,29 @@ export const PATCH = apiHandler( await auditLog("illness.episode.update", { userId: user.id, ipAddress: getClientIp(request), - details: { episodeId: id }, + // C4 — the replaced fields; the note is named and never quoted. + details: { + episodeId: id, + ...overwriteDetails({ + before: { + label: existing.label, + type: existing.type, + lifecycle: existing.lifecycle, + onsetAt: existing.onsetAt, + resolvedAt: existing.resolvedAt, + parentConditionId: existing.parentConditionId, + }, + after: { + label: updated.label, + type: updated.type, + lifecycle: updated.lifecycle, + onsetAt: updated.onsetAt, + resolvedAt: updated.resolvedAt, + parentConditionId: updated.parentConditionId, + }, + redacted: entry.note !== undefined ? ["note"] : [], + }), + }, }); annotate({ @@ -167,7 +198,9 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft delete with a restore route beside it; the day + // logs under the episode come back with it. + const { user } = await requireRecordAuth("manage", "illness"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts index 1bc0bd5af..5345c1723 100644 --- a/src/app/api/insights/biomarker-assessment/route.ts +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -31,7 +31,10 @@ const querySchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 25e3af1da..380a68ad2 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -4,7 +4,7 @@ import { generateBloodPressureStatusForUser, resolveBloodPressureStatusLocale, } from "@/lib/insights/blood-pressure-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index adfc00242..f4dff8de7 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -4,7 +4,7 @@ import { generateBmiStatusForUser, resolveBmiStatusLocale, } from "@/lib/insights/bmi-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts index ee8cdf25b..c0c3cd287 100644 --- a/src/app/api/insights/breathing-screening/route.ts +++ b/src/app/api/insights/breathing-screening/route.ts @@ -15,7 +15,7 @@ * session. */ import { apiError, apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -27,7 +27,9 @@ export const dynamic = "force-dynamic"; const WINDOW_DAYS = 30; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 6525c661f..7f1734587 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -6,7 +6,7 @@ * to the iOS Insight model (id, title, summary, severity, recommendations, * provider). */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiSuccess } from "@/lib/api-response"; @@ -65,7 +65,9 @@ interface InsightCard { export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; // v1.4.31 — the iOS cards adapter feeds the same per-metric diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 443d1597b..5064aef78 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -33,7 +33,9 @@ const coachReadQuerySchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 772cb16e5..e0919bb23 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -22,7 +22,11 @@ import { } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; +import { + apiHandler, + requireRecordAuth, + type AuthContext, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; @@ -41,7 +45,9 @@ import { userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 30f18ca3f..8c03fdcde 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -15,7 +15,7 @@ * lives in `src/lib/insights/correlation-discovery.ts`; this route only * fetches + day-keys + responds. No LLM, no narrative, no cache table. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; @@ -70,7 +70,9 @@ function tzDayKey(at: Date, tz: string): string { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index bb21aa98b..54cd3b294 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -24,7 +24,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import pLimit from "p-limit"; import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -115,17 +115,24 @@ function parseBatchItems(csv: string): { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: the deterministic batch form, no provider on + // the path. + const { user, actor } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); - // Per-user limiter, same posture as the compliance routes: the cold + // Per-caller limiter, same posture as the compliance routes: the cold // build fans out up to 24 rollup walks, so an unthrottled caller could // hammer the pool past the cache. 30/min covers the dashboard's one // canonical read plus retries with a wide margin. + // + // v1.37.0 — the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`: a manager hammering this route burns their own + // allowance rather than locking the owner out, and cannot collect a fresh + // one by switching records. const rl = await checkRateLimit( - `insights-derived-batch:${user.id}`, + `insights-derived-batch:${actor.id}`, 30, 60_000, ); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index df2e7d197..7d6b716e9 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -21,7 +21,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -69,7 +69,10 @@ const derivedQuerySchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/ecg/[id]/route.ts b/src/app/api/insights/ecg/[id]/route.ts index ac0d0c31b..1be6a5351 100644 --- a/src/app/api/insights/ecg/[id]/route.ts +++ b/src/app/api/insights/ecg/[id]/route.ts @@ -26,7 +26,7 @@ import { NextRequest } from "next/server"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -43,7 +43,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 76b10a488..7cdf4a65f 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -40,7 +40,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -106,7 +106,11 @@ const ecgIngestSchema = z .strict(); export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: the record's own recordings, listed without + // touching the encrypted waveform. The POST below is a device ingest and + // keeps `requireAuth()`: a delegate's phone must never move somebody else's + // sync. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/glp1-plateau/route.ts b/src/app/api/insights/glp1-plateau/route.ts index 10a8d6d92..6048eee88 100644 --- a/src/app/api/insights/glp1-plateau/route.ts +++ b/src/app/api/insights/glp1-plateau/route.ts @@ -13,7 +13,7 @@ * beyond auth. Auth via cookie or Bearer. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { @@ -24,7 +24,9 @@ import { export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const plateau = await detectGlp1Plateau(user.id); diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 8992b233f..b7ab3184d 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -10,7 +10,7 @@ */ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { readNote } from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; @@ -66,7 +66,9 @@ function parseTagList(raw: string | null): string[] { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const url = new URL(request.url); const rawLimit = Number(url.searchParams.get("limit")); const limit = diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 0b179058a..99ba3fd15 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -15,7 +15,7 @@ * MeasurementType, so the wire carries the type tokens only. */ import { apiError, apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -28,7 +28,9 @@ import { summariseHealthStatus } from "@/lib/insights/health-status"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 518fc417a..bfbf07b1a 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -14,7 +14,7 @@ * session; soft-deleted rows (`deletedAt`) are filtered out. */ import { apiError, apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -24,7 +24,9 @@ import { summariseLabChanges } from "@/lib/insights/labs-changes"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index c4922b321..691f6903f 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -4,7 +4,7 @@ import { generateMedicationComplianceStatusForUser, resolveMedicationComplianceStatusLocale, } from "@/lib/insights/medication-compliance-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index be45da6b1..ac173fd2e 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -20,7 +20,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -68,7 +68,10 @@ const metricQuerySchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index fcf72828d..f2ad24afc 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -4,7 +4,7 @@ import { generateMoodStatusForUser, resolveMoodStatusLocale, } from "@/lib/insights/mood-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index fd707f024..78c1a1f0a 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -14,7 +14,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; @@ -26,6 +26,7 @@ import { type NarrativePeriod, } from "@/lib/insights/narrative/period-narrative"; import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation"; export const dynamic = "force-dynamic"; @@ -52,7 +53,10 @@ function narrowLocale(locale: Locale): Locale { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); @@ -87,15 +91,26 @@ export const GET = apiHandler(async (request: NextRequest) => { // provider-less account (incl. the no-key demo) gets a non-empty // retrospective on the next read. The enqueue's singletonKey + the 20 h // freshness window bound this to at most one warm per period per ~day. + // + // v1.37.0 — except on a delegated request. This route warms unconditionally + // rather than on a miss, so without the check a manager's first navigation + // here is an egress of the owner's record that the owner did not ask for. + // The stale row is still served; only the warm is withheld. let revalidating = false; - if (!isFresh) { + const generationSuppressed = delegatedGenerationSuppressed(); + if (!isFresh && !generationSuppressed) { void enqueueNarrativeWarm({ userId: user.id, period, locale }); revalidating = true; } annotate({ action: { name: "insights.narrative" }, - meta: { period, has_narrative: existing !== null, revalidating }, + meta: { + period, + has_narrative: existing !== null, + revalidating, + generation_suppressed: generationSuppressed, + }, }); return apiSuccess({ diff --git a/src/app/api/insights/patterns/[id]/route.ts b/src/app/api/insights/patterns/[id]/route.ts index 3363633f2..f4827a1cf 100644 --- a/src/app/api/insights/patterns/[id]/route.ts +++ b/src/app/api/insights/patterns/[id]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -21,7 +21,11 @@ const patternIdSchema = z.string().min(1).max(128); export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE: dismissing a pattern hides a finding on somebody + // else's front door, so it opens at the level that manages the record and + // at no level below. The dismissal stores its own evidence hash and effect + // size, so it reads back and reverses. + const { user } = await requireRecordAuth("manage", "record"); const gate = await requireModuleEnabled(user.id, "insights"); if (!gate.enabled) return gate.response; const parsedId = patternIdSchema.safeParse((await params).id); diff --git a/src/app/api/insights/patterns/route.ts b/src/app/api/insights/patterns/route.ts index 5849bdfb1..828f70d47 100644 --- a/src/app/api/insights/patterns/route.ts +++ b/src/app/api/insights/patterns/route.ts @@ -1,4 +1,4 @@ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -7,7 +7,9 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const gate = await requireModuleEnabled(user.id, "insights"); if (!gate.enabled) return gate.response; const patterns = await prisma.correlationPattern.findMany({ diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index dd22e87f8..6366fcc71 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -4,7 +4,7 @@ import { generatePulseStatusForUser, resolvePulseStatusLocale, } from "@/lib/insights/pulse-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts index 9dc332948..e8e4501f4 100644 --- a/src/app/api/insights/pulse/intraday/route.ts +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -18,7 +18,7 @@ */ import { z } from "zod"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; @@ -37,7 +37,9 @@ const querySchema = z.object({ }); export const GET = apiHandler(async (req: Request) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 12557614a..8ea59813a 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -25,7 +25,7 @@ * provider call — this is a pure DB read). */ import { apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -44,7 +44,9 @@ const EVENT_TYPE_LIST = Array.from( ) as MeasurementType[]; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 7c2a2a1ad..315f0a691 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -6,7 +6,7 @@ * walk can be unit-tested without the route shell. */ import { apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; @@ -15,7 +15,9 @@ import { buildTargetsResponse } from "@/lib/targets/build-response"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: computed over the whole record, with no + // provider anywhere on the path. + const { user } = await requireRecordAuth("manage", "record"); // v1.16.8 — stale-while-revalidate read. The cold build is the multi-query // walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body // serves immediately while ONE background rebuild warms the cell. Writes diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 124eaff0f..275dc3da7 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -4,7 +4,7 @@ import { generateWeightStatusForUser, resolveWeightStatusLocale, } from "@/lib/insights/weight-status"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -13,7 +13,10 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE-level read: a generated assessment over the whole + // record, which is not a section a scoped grant can name. The miss behind it + // enqueues nothing while a delegate is holding the request. + const { user } = await requireRecordAuth("manage", "record"); const m = await requireModuleEnabled(user.id, "insights"); if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index bd1cb62d9..d143259fb 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { @@ -90,7 +91,10 @@ export const GET = apiHandler( export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting a transcribed lab result is the ordinary + // reason somebody manages a record; the previous numbers ride the audit + // row below. + const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; const existing = await prisma.labResult.findFirst({ @@ -116,17 +120,16 @@ export const PUT = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "labs.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, labResultId: id }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, labResultId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "labs.update.invalid", }); @@ -223,7 +226,34 @@ export const PUT = apiHandler( await auditLog("labResult.update", { userId: user.id, ipAddress: getClientIp(request), - details: { labResultId: id }, + // C4 — the replaced result. The note is named and never quoted: it is + // encrypted on the row. + details: { + labResultId: id, + ...overwriteDetails({ + before: { + analyte: existing.analyte, + value: existing.value, + valueText: existing.valueText, + unit: existing.unit, + panel: existing.panel, + referenceLow: existing.referenceLow, + referenceHigh: existing.referenceHigh, + takenAt: existing.takenAt, + }, + after: { + analyte: updated.analyte, + value: updated.value, + valueText: updated.valueText, + unit: updated.unit, + panel: updated.panel, + referenceLow: updated.referenceLow, + referenceHigh: updated.referenceHigh, + takenAt: updated.takenAt, + }, + redacted: d.note !== undefined ? ["note"] : [], + }), + }, }); annotate({ action: { name: "labs.update" }, meta: { labResultId: id } }); @@ -236,7 +266,9 @@ export const PUT = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft-delete with `labs/restore` beside it, so the + // reading is reconstructable from the row itself. + const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; const existing = await prisma.labResult.findFirst({ diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts index 24c734675..b4eeacf18 100644 --- a/src/app/api/labs/restore/route.ts +++ b/src/app/api/labs/restore/route.ts @@ -13,7 +13,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiError, @@ -39,10 +39,17 @@ const restoreSchema = z.object({ export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore)); async function postRestore(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The undo beside a delete the level already admits; the + // `where` only ever clears a tombstone the record already carried, so a + // foreign or live id is a no-op. + const { user, actor } = await requireRecordAuth("manage", "labs"); + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `labs:restore:${user.id}`, + `labs:restore:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 1a9ac856f..97c9fb415 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -18,6 +18,14 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + // v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the + // caller and equals `user` for everyone acting on their own reminders, + // which is what this suite exercises. + requireRecordAuth: vi.fn(async () => ({ + user: { id: "u1", locale: "en" }, + actor: { id: "u1", locale: "en" }, + grantId: null, + })), })); vi.mock("@/lib/auth/audit", () => ({ diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts index 2020c27b6..5a48a0bf1 100644 --- a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -12,6 +12,14 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + // v1.37.0 — the mutating arms resolve the RECORD at MANAGE. `actor` is the + // caller and equals `user` for everyone acting on their own reminders, + // which is what this suite exercises. + requireRecordAuth: vi.fn(async () => ({ + user: { id: "u1", locale: "en" }, + actor: { id: "u1", locale: "en" }, + grantId: null, + })), })); vi.mock("@/lib/auth/audit", () => ({ diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index 408bf12c8..2a4225f52 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -24,7 +24,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; @@ -36,7 +36,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin"; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Marking a reminder satisfied re-anchors a schedule the level + // already admits arming and editing. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; const existing = await prisma.measurementReminder.findFirst({ diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index a463eaa67..b68acd296 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -10,7 +10,11 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, @@ -62,7 +66,9 @@ export const GET = apiHandler( export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Editing a reminder re-anchors when the record's own + // phone rings, which is management of the schedule the level admits. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; const existing = await prisma.measurementReminder.findFirst({ @@ -87,17 +93,16 @@ export const PATCH = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurement-reminders.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, reminderId: id }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, reminderId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } @@ -183,7 +188,33 @@ export const PATCH = apiHandler( await auditLog("measurementReminder.update", { userId: user.id, ipAddress: getClientIp(request), - details: { reminderId: id }, + // C4 — what the edit replaced. A cadence change moves when somebody's + // phone rings; the feed has to be able to say what it used to be. + details: { + reminderId: id, + ...overwriteDetails({ + before: { + label: existing.label, + measurementType: existing.measurementType, + intervalDays: existing.intervalDays, + rrule: existing.rrule, + anchorDate: existing.anchorDate, + notifyHour: existing.notifyHour, + enabled: existing.enabled, + nextDueAt: existing.nextDueAt, + }, + after: { + label: updated.label, + measurementType: updated.measurementType, + intervalDays: updated.intervalDays, + rrule: updated.rrule, + anchorDate: updated.anchorDate, + notifyHour: updated.notifyHour, + enabled: updated.enabled, + nextDueAt: updated.nextDueAt, + }, + }), + }, }); annotate({ @@ -197,7 +228,10 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and the one verb in this family that destroys without + // a tombstone: the row goes, so the audit row below is the only thing left + // that knows what it was for. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; const existing = await prisma.measurementReminder.findUnique({ @@ -218,7 +252,25 @@ export const DELETE = apiHandler( await auditLog("measurementReminder.delete", { userId: user.id, ipAddress: getClientIp(request), - details: { reminderId: id }, + // C3 — a hard delete, so the details carry the identity of what went: + // the label the owner wrote, what it watched, when it was next due. An + // id alone would say a preventive-care recall disappeared and never say + // what it was for. + details: { + reminderId: id, + ...destroyedDetails({ + model: "MeasurementReminder", + id, + label: existing.label, + effectiveAt: existing.nextDueAt, + extra: { + measurementType: existing.measurementType, + intervalDays: existing.intervalDays, + rrule: existing.rrule, + origin: existing.origin, + }, + }), + }, }); annotate({ diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index 95233b768..f877d3b87 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -10,7 +10,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; @@ -22,7 +22,9 @@ const DEFAULT_TIMEZONE = "Europe/Berlin"; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The same primitive from the checklist side re-anchors a schedule the level + // already admits arming and editing. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; const existing = await prisma.measurementReminder.findFirst({ diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 045504c64..8b22c5721 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -8,7 +8,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, getClientIp, @@ -57,7 +57,10 @@ export const GET = apiHandler(async () => { }); async function postReminder(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. A reminder armed by a manager rings the record's own + // phone, which is the person the reminder is about, and it is additive: the + // row is listed, editable and removable by the owner. + const { user } = await requireRecordAuth("manage", "measurements"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, @@ -74,17 +77,16 @@ async function postReminder(request: NextRequest): Promise { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurement-reminders.create.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts index 6029e657b..97d7f7c7c 100644 --- a/src/app/api/measurements/[id]/__tests__/route.test.ts +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -42,6 +42,7 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; @@ -74,7 +75,11 @@ const EXISTING = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.37.0 — the breadcrumb goes through `auditLog()` now (the only writer + // that stamps the actor). `resetAllMocks` clears the factory's resolved + // value and the route chains `.catch()` on the call, so it has to resolve + // again here or every 422 path throws. + vi.mocked(auditLog).mockResolvedValue(undefined as never); // resetAllMocks clears the factory implementation; the route chains // `.catch()` on this fire-and-forget call, so it must resolve again. vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue( @@ -136,18 +141,17 @@ describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => { ); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; - expect(call.data.userId).toBe("user-1"); - expect(call.data.action).toBe("measurements.update.validation-failed"); + expect(auditLog).toHaveBeenCalledTimes(1); + const [action, opts] = vi.mocked(auditLog).mock.calls[0] as [ + string, + { userId: string }, + ]; + expect(action).toBe("measurements.update.validation-failed"); + expect(opts.userId).toBe("user-1"); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await PUT( putReq({ value: "junk", measuredAt: "junk" }), ROUTE_CTX, diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index d3cd83a16..1c425ce1c 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -1,7 +1,8 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -57,7 +58,10 @@ export const GET = apiHandler( export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting somebody else's reading is management of + // their record, not a contribution to it, and the previous value is gone + // the moment this lands: the audit row below carries it. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; @@ -91,20 +95,19 @@ export const PUT = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurements.update.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - measurementId: id, - }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurements.update.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + measurementId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } @@ -202,7 +205,21 @@ export const PUT = apiHandler( await auditLog("measurement.update", { userId: user.id, ipAddress: getClientIp(request), - details: { measurementId: id }, + // C4 — the overwritten scalars. Without them the owner's feed says a + // reading changed and cannot say from what. The note is named and never + // quoted: it is encrypted on the row and copying it here would make the + // audit table a second store for it. + details: { + measurementId: id, + ...overwriteDetails({ + before: { value: existing.value, measuredAt: existing.measuredAt }, + after: { + value: measurement.value, + measuredAt: measurement.measuredAt, + }, + redacted: data.notes !== undefined ? ["notes"] : [], + }), + }, }); annotate({ @@ -260,7 +277,10 @@ export const PUT = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The row tombstones and `measurements/restore` puts it + // back, so the destruction is reversible by the owner and by whoever did + // it; the audit row already names the reading and its type. + const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 443d09682..b70ab26de 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -17,7 +17,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -48,10 +48,17 @@ const bulkDeleteSchema = z.object({ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete)); async function postBulkDelete(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Every row tombstones and `measurements/restore` puts + // them back, so reconstruction rides the rows themselves rather than the + // audit details. + const { user, actor } = await requireRecordAuth("manage", "measurements"); + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `measurements:bulk-delete:${user.id}`, + `measurements:bulk-delete:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 1e3fa8d01..8c2231473 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -50,10 +50,16 @@ const restoreSchema = z.object({ export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore)); async function postRestore(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. A manager who holds the delete holds the undo; the + // `where` below only ever clears a tombstone the record already carried. + const { user, actor } = await requireRecordAuth("manage", "measurements"); + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `measurements:restore:${user.id}`, + `measurements:restore:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 220c1d7b2..e32baa702 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -15,7 +15,7 @@ */ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -224,17 +224,22 @@ export const GET = apiHandler( */ export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. A titration step is record data and the dose is what + // the server acts on; administering one is the case the level names. + const { user, actor } = await requireRecordAuth("manage", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - // Per-user POST rate-limit — matches the 30/min sibling routes + // Per-caller POST rate-limit — matches the 30/min sibling routes // (inventory, side-effects). Generous for a hand-driven session, // tight enough to cut off the spam case. + // + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. const rl = await checkRateLimit( - `medication-glp1:post:${user.id}`, + `medication-glp1:post:${actor.id}`, POST_RATE_LIMIT, POST_WINDOW_MS, ); diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index d355bf403..2e2806a5e 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -87,6 +87,9 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.37.0 — `resetAllMocks` clears the factory's resolved value and the + // route chains `.catch()` on this fire-and-forget call. + vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({ id: "e1", userId: "user-1", @@ -161,19 +164,19 @@ describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.4 ); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; - expect(call.data.action).toBe( + // v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer + // that stamps `actorUserId`. + const breadcrumbs = vi + .mocked(auditLog) + .mock.calls.filter(([name]) => name.endsWith("validation-failed")); + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.[0]).toBe( "medications.intake.event.update.validation-failed", ); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await PUT( putReq({ takenAt: "not-iso", skipped: "string" }), ROUTE_CTX, diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index c4843c102..ecd12aeba 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -1,7 +1,8 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -32,7 +33,10 @@ type RouteParams = { params: Promise<{ id: string; eventId: string }> }; export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting a dose tombstones the original and + // re-creates it on the corrected slot, so the write is reconstructable by + // the rows; what it must not do is restamp where the dose came from (C8). + const { user } = await requireRecordAuth("manage", "medications"); const { id, eventId } = await params; @@ -65,21 +69,20 @@ export const PUT = apiHandler( event_id: eventId, }, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.event.update.validation-failed", - details: JSON.stringify({ - issues, - medicationId: id, - eventId, - }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("medications.intake.event.update.validation-failed", { + userId: user.id, + details: { + issues, + medicationId: id, + eventId, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } @@ -307,7 +310,11 @@ export const PUT = apiHandler( isExplicitTaken: !nextSkipped && nextTakenAt !== null, isExplicitSkip: nextSkipped, idempotencyKey: null, - createSource: "WEB", + // C8 — the converged row keeps the ORIGINAL's provenance. This used + // to be a hardcoded `"WEB"`, which said "somebody typed this in the + // browser" about an Apple Health dose whose time was corrected. Under + // MANAGE the sentence would be wrong about a second person as well. + createSource: event.source, attributionSource, // v1.16.9 — a slot move is a re-binding, not a new dose: the // original row's recorded injection site and dose override ride @@ -339,7 +346,28 @@ export const PUT = apiHandler( await auditLog("medication.intake.update", { userId: user.id, ipAddress: getClientIp(request), - details: { eventId, medicationId: id }, + // C4 — what the correction moved. A dose edit changes when a dose is + // recorded as taken, which is exactly what a clinician reads back. + details: { + eventId, + medicationId: id, + ...overwriteDetails({ + before: { + scheduledFor: event.scheduledFor, + takenAt: event.takenAt, + skipped: event.skipped, + }, + // The slot-write result carries the canonical four. The injection + // site and the dose override are not compared here because the move + // path copies them onto the converged row by construction — there is + // no edit of them to report. + after: { + scheduledFor: updated.scheduledFor, + takenAt: updated.takenAt, + skipped: updated.skipped, + }, + }), + }, }); annotate({ @@ -394,7 +422,9 @@ export const PUT = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft delete: the row stays, the sync feed carries the + // tombstone, and the inventory stamp is refunded. + const { user } = await requireRecordAuth("manage", "medications"); const { id, eventId } = await params; diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 0ea7d91be..4596a5ed1 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -59,17 +59,22 @@ const BULK_DELETE_WINDOW_MS = 60_000; */ export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Tombstoning a run of doses; every row stays and the + // sync feed carries each one. + const { user, actor } = await requireRecordAuth("manage", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - // v1.5.5 F-1 H-6 — per-user cap matches the glp1 POST so a caller + // v1.5.5 F-1 H-6 — per-caller cap matches the glp1 POST so a caller // firing repeated 500-id bulk deletes cannot pin the per-dayKey // recompute path. + // + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. const rl = await checkRateLimit( - `medication-intake-bulk-delete:${user.id}`, + `medication-intake-bulk-delete:${actor.id}`, BULK_DELETE_RATE_LIMIT, BULK_DELETE_WINDOW_MS, ); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 9cf7f4282..66b2ee576 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -59,6 +59,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -91,6 +92,9 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.37.0 — `resetAllMocks` clears the factory's resolved value and the + // route chains `.catch()` on this fire-and-forget call. + vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 60, @@ -179,19 +183,19 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 ); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; - expect(call.data.action).toBe( + // v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer + // that stamps `actorUserId`. + const breadcrumbs = vi + .mocked(auditLog) + .mock.calls.filter(([name]) => name.endsWith("validation-failed")); + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.[0]).toBe( "medications.intake.import.validation-failed", ); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await POST( postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]), ROUTE_CTX, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 42c4f7226..1cd245381 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -98,7 +98,12 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The import is additive with duplicates skipped, every + // written row tombstones, and the provenance it stamps (`IMPORT`) stays + // true under delegation because it describes where the data came from and + // not who typed it. C9: the enqueued job outlives this request, so the + // kickoff audit row below joins it by `jobId`. + const { user, actor } = await requireRecordAuth("manage", "medications"); const { id } = await params; // v1.5.5 C-E3-3 — route ownership through the shared helper so the @@ -106,8 +111,10 @@ export const POST = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. const rl = await checkRateLimit( - `medications:intake:import:${user.id}`, + `medications:intake:import:${actor.id}`, IMPORT_RATE_LIMIT_MAX, IMPORT_RATE_LIMIT_WINDOW_MS, ); @@ -147,20 +154,19 @@ export const POST = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.import.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - medicationId: id, - }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("medications.intake.import.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + medicationId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "medication.intake.import.invalid_format", }); diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 34af5204b..da2e249ec 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; @@ -12,7 +12,10 @@ type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. It tombstones rather than deletes (since the + // delegated-write release) and drops only rollups that recompute, so the + // history it clears is reconstructable from the rows themselves. + const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; // v1.5.5 C-E3-3 — route purge through the same ownership predicate diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index e371b526e..356e44167 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -21,8 +21,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; import { annotate } from "@/lib/logging/context"; import { apiError, @@ -45,7 +49,8 @@ type RouteParams = { params: Promise<{ id: string; itemId: string }> }; export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting a stock item's state or count. + const { user } = await requireRecordAuth("manage", "medications"); const { id, itemId } = await params; const { data: body, error: jsonError } = await safeJson(request, { @@ -193,20 +198,46 @@ export const PATCH = apiHandler( }, }); - return { prevState: existing.state, updated }; + return { + prevState: existing.state, + // v1.37.0 — the pre-image the audit row carries (C4). + previous: { + unitsRemaining: Number(existing.unitsRemaining), + unitsTotal: Number(existing.unitsTotal), + firstUseAt: existing.firstUseAt, + printedExpiry: existing.printedExpiry, + }, + updated, + }; }); if ("notFound" in outcome) return apiError("Inventory item not found", 404); - const { prevState, updated } = outcome; + const { prevState, previous, updated } = outcome; await auditLog("medication.inventory.update", { userId: user.id, ipAddress: getClientIp(request), + // C4 — the counts and dates this edit replaced, beside the state + // transition the row already carried. details: { medicationId: id, itemId, prevState, nextState: updated.state, + ...overwriteDetails({ + before: { + unitsRemaining: previous.unitsRemaining, + unitsTotal: previous.unitsTotal, + firstUseAt: previous.firstUseAt, + printedExpiry: previous.printedExpiry, + }, + after: { + unitsRemaining: Number(updated.unitsRemaining), + unitsTotal: Number(updated.unitsTotal), + firstUseAt: updated.firstUseAt, + printedExpiry: updated.printedExpiry, + }, + }), }, }); @@ -231,7 +262,9 @@ export const PATCH = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and a hard delete. The audit row already carried the + // final state and the units left; C3 adds what the container WAS. + const { user } = await requireRecordAuth("manage", "medications"); const { id, itemId } = await params; // v1.32.22 (M5) — take the same per-medication advisory lock the intake @@ -255,11 +288,27 @@ export const DELETE = apiHandler( await auditLog("medication.inventory.delete", { userId: user.id, ipAddress: getClientIp(request), + // C3 — the container's own identity: its label, its expiry, what it + // held. The row is gone; this is what is left of it. details: { medicationId: id, itemId, finalState: existing.state, - unitsRemaining: existing.unitsRemaining, + unitsRemaining: Number(existing.unitsRemaining), + ...destroyedDetails({ + model: "MedicationInventoryItem", + id: itemId, + label: + existing.doseStrength ?? + existing.manufacturer ?? + existing.containerType, + effectiveAt: existing.expiresAt ?? existing.printedExpiry, + extra: { + containerType: existing.containerType, + unitsTotal: Number(existing.unitsTotal), + printedExpiry: existing.printedExpiry, + }, + }), }, }); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 772b3fb49..e15529cb7 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -6,7 +6,8 @@ * * Per-item operations live on the `[itemId]` sub-route. * - * Auth: cookie-session via requireAuth(); the medication is verified + * Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the + * medication is verified * to belong to the caller before any read/write touches inventory. * Rate-limit 30/min/user on the POST path (creation is the only * surface that can spam writes; GET is read-only and benign). @@ -16,7 +17,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -91,16 +92,22 @@ export const GET = apiHandler( export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Recording the stock the low-stock notification reads; + // that notification rings the record's own phone. + const { user, actor } = await requireRecordAuth("manage", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - // Per-user POST rate-limit — 30/min is generous for normal pen + // Per-caller POST rate-limit — 30/min is generous for normal pen // registrations but cuts off the spam case. + // + // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their + // own allowance rather than the owner's and cannot collect a fresh one by + // switching records. const rl = await checkRateLimit( - `medication-inventory:post:${user.id}`, + `medication-inventory:post:${actor.id}`, POST_RATE_LIMIT, POST_WINDOW_MS, ); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 877ef0c41..01ac66aeb 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -187,7 +188,13 @@ export const GET = apiHandler( export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and the strongest candidate in the domain: a manager + // administering somebody's regimen is the case the level exists for. What + // it destroys is scheduling anchors, not history — the tombstone `where` + // below excludes every actioned row — and the previous cadence is the one + // genuinely unrecoverable part, which is what C7 puts in the audit row. + // The DELETE beside it stays refused. + const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; // v1.5.5 C-E3-3 — route ownership check through the shared helper @@ -206,6 +213,10 @@ export const PUT = apiHandler( active: true, createdAt: true, asNeeded: true, + // v1.37.0 — the pre-image the audit row carries (C4). + name: true, + dose: true, + endsOn: true, _count: { select: { schedules: true } }, }, }); @@ -440,6 +451,17 @@ export const PUT = apiHandler( }; }); + // v1.37.0 — C7. What a schedule replacement destroys, captured for the + // audit row: the cadence that was there and the pending slots that were + // tombstoned with it. A feed line saying "changed a medication" cannot + // answer "back to what?", and unlike a row a schedule keeps ACTING after + // the manager's grant ends. + let replacedCadence: string | null = null; + let replacedScheduleCount: number | null = null; + let tombstonedSlotCount: number | null = null; + let tombstonedFrom: Date | null = null; + let tombstonedTo: Date | null = null; + // If schedules provided, replace all if (schedules && normalisedSchedules) { // v1.16.3 — effective dating. Before the wholesale replace below wipes @@ -467,6 +489,25 @@ export const PUT = apiHandler( reminderGraceMinutes: row.reminderGraceMinutes, }), ); + replacedScheduleCount = previousRows.length; + replacedCadence = + previousRows.length > 0 + ? previousRows + .map((row) => + [ + row.timesOfDay, + row.rrule ?? "", + row.rollingIntervalDays !== null + ? `every ${row.rollingIntervalDays}d` + : "", + row.daysOfWeek ?? "", + ] + .filter((part) => part !== "") + .join(" "), + ) + .join(" | ") + : null; + if ( previousRows.length > 0 && schedulesMateriallyDiffer( @@ -564,6 +605,16 @@ export const PUT = apiHandler( where: tombstoneWhere, select: { scheduledFor: true }, }); + // C7 — the count and the day range of the anchors that went. + tombstonedSlotCount = tombstoned.length; + for (const row of tombstoned) { + if (tombstonedFrom === null || row.scheduledFor < tombstonedFrom) { + tombstonedFrom = row.scheduledFor; + } + if (tombstonedTo === null || row.scheduledFor > tombstonedTo) { + tombstonedTo = row.scheduledFor; + } + } await prisma.medicationIntakeEvent.updateMany({ where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, @@ -729,7 +780,37 @@ export const PUT = apiHandler( await auditLog("medication.update", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: id }, + // C4 on what the medication row itself lost, and C7 on the schedule: + // the cadence that was replaced, and how many pending slots went with + // it over which days. + details: { + medicationId: id, + ...overwriteDetails({ + before: { + name: existing.name, + dose: existing.dose, + active: existing.active, + asNeeded: existing.asNeeded, + endsOn: existing.endsOn, + }, + after: { + name: medication.name, + dose: medication.dose, + active: medication.active, + asNeeded: medication.asNeeded, + endsOn: medication.endsOn, + }, + }), + ...(replacedScheduleCount !== null + ? { + replacedCadence, + replacedScheduleCount, + tombstonedSlotCount, + tombstonedFrom: tombstonedFrom?.toISOString() ?? null, + tombstonedTo: tombstonedTo?.toISOString() ?? null, + } + : {}), + }, }); annotate({ diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index 823f07ed6..1a4d83212 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -25,9 +25,13 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; import { apiError, apiSuccess, @@ -37,7 +41,10 @@ import { } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; -import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; +import { + toRevisionPayloadEntry, + type ScheduleRevisionEntry, +} from "@/lib/medications/scheduling/schedule-eras"; import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -46,7 +53,9 @@ type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting an era the compliance engine reads back + // over months of somebody's history. + const { user } = await requireRecordAuth("manage", "medications"); const { id, revisionId } = await params; const guard = await assertMedicationOwnership(id, user.id); @@ -116,6 +125,8 @@ export const PATCH = apiHandler( source: true, supersededByRevisionId: true, validUntil: true, + // v1.37.0 — the pre-image the audit row carries (C4). + validFrom: true, }, }); if (!target || target.medicationId !== id) { @@ -182,6 +193,10 @@ export const PATCH = apiHandler( source: true, } as const; + const previousBounds = { + validFrom: target.validFrom, + validUntil: target.validUntil, + }; if (target.source === "MANUAL") { const revision = await tx.medicationScheduleRevision.update({ where: { id: revisionId }, @@ -192,7 +207,12 @@ export const PATCH = apiHandler( }, select: revisionSelect, }); - return { ok: true as const, revision, mode: "in_place" as const }; + return { + ok: true as const, + revision, + mode: "in_place" as const, + previousBounds, + }; } // ARCHIVED — immutable. Mint the correction as a MANUAL row and @@ -211,7 +231,12 @@ export const PATCH = apiHandler( where: { id: revisionId }, data: { supersededByRevisionId: revision.id }, }); - return { ok: true as const, revision, mode: "supersede" as const }; + return { + ok: true as const, + revision, + mode: "supersede" as const, + previousBounds, + }; }); if (!outcome.ok) { @@ -221,6 +246,9 @@ export const PATCH = apiHandler( await auditLog("medication.schedule_revision.updated", { userId: user.id, ipAddress: getClientIp(request), + // C4 — the era boundaries the correction replaced. An era is a window + // the compliance engine reads months of history through; moving it + // silently re-scores days nobody looked at again. details: { medicationId: id, revisionId, @@ -228,6 +256,16 @@ export const PATCH = apiHandler( ...(outcome.mode === "supersede" && { correctionRevisionId: outcome.revision.id, }), + ...overwriteDetails({ + before: { + validFrom: outcome.previousBounds.validFrom, + validUntil: outcome.previousBounds.validUntil, + }, + after: { + validFrom: outcome.revision.validFrom, + validUntil: outcome.revision.validUntil, + }, + }), }, }); @@ -273,9 +311,32 @@ export const PATCH = apiHandler( }, ); +/** + * v1.37.0 — a one-line rendering of an era's dosing plan, for the audit row + * a hard delete leaves behind (C3). Defensive by construction: a malformed + * payload degrades to an empty string rather than throwing on a delete path. + */ +function summarisePayloadForAudit(payload: unknown): string { + if (!Array.isArray(payload)) return ""; + return payload + .map((raw) => { + const entry = (raw ?? {}) as Partial; + const times = Array.isArray(entry.timesOfDay) + ? entry.timesOfDay.filter((t): t is string => typeof t === "string") + : []; + const dose = typeof entry.dose === "string" ? entry.dose : null; + return [times.join(","), dose].filter(Boolean).join(" "); + }) + .filter((part) => part !== "") + .join(" | "); +} + export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE, and a hard delete: the era row goes and the + // supersede chain is repaired behind it. C3 below is what makes the era + // readable back out of the feed. + const { user } = await requireRecordAuth("manage", "medications"); const { id, revisionId } = await params; const guard = await assertMedicationOwnership(id, user.id); @@ -283,7 +344,16 @@ export const DELETE = apiHandler( const revision = await prisma.medicationScheduleRevision.findUnique({ where: { id: revisionId }, - select: { id: true, medicationId: true, source: true }, + // v1.37.0 — the era's own dates and payload summary go into the audit + // row (C3); the row itself does not survive the delete. + select: { + id: true, + medicationId: true, + source: true, + validFrom: true, + validUntil: true, + payload: true, + }, }); if (!revision || revision.medicationId !== id) { return apiError("Schedule revision not found", 404); @@ -309,7 +379,20 @@ export const DELETE = apiHandler( await auditLog("medication.schedule_revision.deleted", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: id, revisionId }, + // C3 — the era's dates and its dosing summary. Without them the feed + // says an era was deleted and the compliance history it explained + // becomes unexplainable. + details: { + medicationId: id, + revisionId, + ...destroyedDetails({ + model: "MedicationScheduleRevision", + id: revisionId, + label: summarisePayloadForAudit(revision.payload), + effectiveAt: revision.validFrom, + extra: { validUntil: revision.validUntil }, + }), + }, }); annotate({ diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 5a227284a..20994aa84 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -16,14 +16,15 @@ * min/max of the times) so the era minter — ledger, compliance, * cadence chips — reads it like any other revision. * - * Auth: cookie-session or Bearer via requireAuth(); the medication is + * Auth: cookie-session or Bearer, resolved through `requireRecordAuth`; the + * medication is * verified to belong to the caller before any read or write. Deleting * a manual era lives in the `[revisionId]` sub-route. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -135,7 +136,9 @@ export const GET = apiHandler( export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Archiving a schedule era the compliance engine reads: + // additive, audited, and reversible through the DELETE beside it. + const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/side-effects/[logId]/route.ts b/src/app/api/medications/[id]/side-effects/[logId]/route.ts index 975e0797f..849f28cb6 100644 --- a/src/app/api/medications/[id]/side-effects/[logId]/route.ts +++ b/src/app/api/medications/[id]/side-effects/[logId]/route.ts @@ -15,7 +15,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; @@ -38,7 +38,10 @@ async function loadOwnedRow( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The one hard delete in the tree whose audit details + // already reconstruct the row: entry, severity and the date it happened + // are on the row this handler writes. + const { user } = await requireRecordAuth("manage", "medications"); const { id, logId } = await params; const existing = await loadOwnedRow(id, logId, user.id); diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index 731a8e5a3..34cf6d54a 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -29,7 +29,7 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -97,7 +97,11 @@ function describePlan(plan: AutoExportPlan) { } export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The whole-regimen form of the per-medication import: + // same worker, same additive-with-skip-duplicates properties, same honest + // `IMPORT` provenance. C9: the kickoff audit row below joins the job by id, + // so work that outlives the grant is still attributable. + const { user, actor } = await requireRecordAuth("manage", "medications"); const dryRunParam = new URL(request.url).searchParams.get("dryRun"); const dryRun = dryRunParam === "1" || dryRunParam === "true"; annotate({ @@ -105,8 +109,11 @@ export const POST = apiHandler(async (request: NextRequest) => { meta: { dry_run: dryRun }, }); + // v1.37.0 — C1: keyed on the ACTOR. The bucket is shared with the other + // import surfaces, so keying it on the record would let a manager consume + // the owner's hourly import budget. const rl = await checkRateLimit( - `import:${user.id}`, + `import:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 7dc6d9dfd..bd4253f9b 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -18,7 +18,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment)); async function postAssessment(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. A screener administered by the manager and transcribed + // into the record: the ordinary way this instrument is used when the person + // it is about cannot run their own record. The row records what the person + // in the room reported; the audit row says who typed it. + const { user, actor } = await requireRecordAuth("manage", "mind"); // Opt-in module (default OFF) — same gate as GET. const gate = await requireModuleEnabled(user.id, "mentalHealth"); @@ -160,8 +164,11 @@ async function postAssessment(request: NextRequest): Promise { // Write rate-limit: a screener is a deliberate, infrequent action, so the // bucket is tight — it only caps a double-tap / scripted loop minting // duplicate trend points (the recall window is 2 weeks; 30/hour is generous). + // v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their own + // allowance rather than the owner's and cannot collect a fresh one by + // switching records. const rl = await checkRateLimit( - `mental-health-create:${user.id}`, + `mental-health-create:${actor.id}`, 30, 60 * 60 * 1000, ); @@ -245,7 +252,14 @@ async function postAssessment(request: NextRequest): Promise { const total = scoreTotal(id, items); const band = severityBand(id, total); const flagged = isSafetyFlagged(id, items); - const presentedLocale = locale ?? user.locale ?? "en"; + // C2 — the locale of the person holding the phone, not of the record. + // `presentedLocale` is used twice: it is stored on the row as what was + // shown, and it selects the crisis-resource copy returned on a positive + // item 9. Under a switch the reader is the MANAGER, and crisis resources in + // a language they cannot read are a safety failure, not a presentation bug. + // `actor` is the caller in every case and equals `user` for everyone acting + // on their own record, so this is the record's own locale for them. + const presentedLocale = locale ?? actor.locale ?? "en"; const when = takenAt ? new Date(takenAt) : new Date(); // Encrypt the per-item answers + the (unscored) functional-impairment follow-up. diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index 308b67d60..ec47b786a 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -60,6 +60,7 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; import { RatedFactorOutOfRangeError, @@ -105,7 +106,9 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "me1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.37.0 — the breadcrumb goes through `auditLog()` now; `resetAllMocks` + // clears the factory's resolved value and the route chains `.catch()`. + vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(prisma.$transaction).mockImplementation(async (callback) => callback(txClient as never), ); @@ -170,17 +173,17 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { ); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; + expect(auditLog).toHaveBeenCalledTimes(1); + const [auditAction, auditOpts] = vi.mocked(auditLog).mock.calls[0] as [ + string, + { userId: string }, + ]; + const call = { data: { userId: auditOpts.userId, action: auditAction } }; expect(call.data.action).toBe("mood-entries.update.validation-failed"); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await PUT( putReq({ mood: "junk", moodLoggedAt: "not-iso" }), ROUTE_CTX, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 723b1c15c..b6f1c4412 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiSuccess, apiError, @@ -11,7 +12,7 @@ import { import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; @@ -62,7 +63,8 @@ export const GET = apiHandler( export const PUT = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Correcting an entry in a record somebody manages. + const { user } = await requireRecordAuth("manage", "mind"); const { id } = await params; @@ -97,17 +99,16 @@ export const PUT = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "mood-entries.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, moodEntryId: id }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, moodEntryId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "mood.update.invalid", }); @@ -227,7 +228,29 @@ export const PUT = apiHandler( await auditLog("moodEntry.update", { userId: user.id, ipAddress: getClientIp(request), - details: { moodEntryId: id }, + // C4 — the replaced fields. The note and the free-text tags are named + // and never quoted; the note is encrypted at rest. + details: { + moodEntryId: id, + ...overwriteDetails({ + before: { + mood: existing.mood, + score: existing.score, + moodLoggedAt: existing.moodLoggedAt, + }, + after: { + mood: entry.mood, + score: entry.score, + moodLoggedAt: entry.moodLoggedAt, + }, + redacted: [ + ...(data.note !== undefined ? ["note"] : []), + ...(data.tags !== undefined ? ["tags"] : []), + ...(data.tagKeys !== undefined ? ["tagKeys"] : []), + ...(data.ratedFactors !== undefined ? ["ratedFactors"] : []), + ], + }), + }, }); annotate({ @@ -280,7 +303,8 @@ export const PUT = apiHandler( export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Soft delete with `mood-entries/restore` beside it. + const { user } = await requireRecordAuth("manage", "mind"); const { id } = await params; diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 71717a25f..f0cfc624f 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -239,17 +239,17 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { const res = await POST(postReq({ mood: "junk" })); expect(res.status).toBe(422); await new Promise((r) => setTimeout(r, 5)); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as { - data: { userId: string; action: string }; - }; - expect(call.data.action).toBe("mood-entries.create.validation-failed"); + // v1.37.0 — the breadcrumb goes through `auditLog()`, the only writer + // that stamps `actorUserId`. + const breadcrumbs = vi + .mocked(auditLog) + .mock.calls.filter(([name]) => name.endsWith("validation-failed")); + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.[0]).toBe("mood-entries.create.validation-failed"); }); it("does not block the 422 when the audit-row write rejects", async () => { - vi.mocked(prisma.auditLog.create).mockRejectedValueOnce( - new Error("db down"), - ); + vi.mocked(auditLog).mockRejectedValueOnce(new Error("db down")); const res = await POST(postReq({ mood: "junk" })); expect(res.status).toBe(422); }); diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts index 96e8c301b..325163f07 100644 --- a/src/app/api/mood-entries/bulk-delete/route.ts +++ b/src/app/api/mood-entries/bulk-delete/route.ts @@ -17,7 +17,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -43,10 +43,16 @@ const bulkDeleteSchema = z.object({ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete)); async function postBulkDelete(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Tombstoning a selection; every row stays and `mood-entries/restore` + // puts it back. + const { user, actor } = await requireRecordAuth("manage", "mind"); + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `mood-entries:bulk-delete:${user.id}`, + `mood-entries:bulk-delete:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/mood-entries/restore/route.ts b/src/app/api/mood-entries/restore/route.ts index 3146061c3..9965558ad 100644 --- a/src/app/api/mood-entries/restore/route.ts +++ b/src/app/api/mood-entries/restore/route.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -45,10 +45,16 @@ const restoreSchema = z.object({ export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore)); async function postRestore(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. The undo beside a delete the level admits; the `where` only ever + // clears a tombstone the record already carried. + const { user, actor } = await requireRecordAuth("manage", "mind"); + // v1.37.0 — C1: the bucket keys on the ACTOR, the frozen precedent from + // `medications/compliance`. A manager burns their own allowance rather than + // locking the owner out of their own record, and cannot collect a fresh one + // by switching records. const rl = await checkRateLimit( - `mood-entries:restore:${user.id}`, + `mood-entries:restore:${actor.id}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, ); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 5fdac86c8..e92baa7d1 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -19,7 +19,7 @@ import { } from "@/lib/validations/external-id"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; @@ -151,7 +151,11 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry)); async function postMoodEntry(request: NextRequest) { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. A mood row entered by a manager is an observation + // transcribed, and the audit trail says so for the retention window. What + // the delegated arm does not get is the sync client's upsert handle; see + // the `externalId` refusal below. + const { user, actor } = await requireRecordAuth("manage", "mind"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, @@ -177,17 +181,16 @@ async function postMoodEntry(request: NextRequest) { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "mood-entries.create.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "mood.create.invalid", }); @@ -215,6 +218,23 @@ async function postMoodEntry(request: NextRequest) { // and the row write agree on the exact value. const resolvedSource = source ?? "MANUAL"; + // C6 — the delegated arm refuses `externalId`. It is the sync client's dedup + // handle: supplying it turns this create into an upsert on + // `(userId, source, externalId)`, which is a device's contract with its own + // rows and not something a second person should be able to aim at somebody + // else's record. A person entering a mood through the UI never sends one. + if (actor.id !== user.id && externalId) { + annotate({ + action: { name: "mood-entries.create.external-id-refused" }, + meta: { delegated: true }, + }); + return apiError( + "externalId cannot be supplied on a record you manage", + 422, + { errorCode: "mood.create.external_id_not_delegable" }, + ); + } + try { // v1.8.5 — write the entry and its structured-tag links in one // transaction. The links are user-intended content, not a cache, so a diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts index 443fe2b55..051a16d14 100644 --- a/src/app/api/nutrients/water/__tests__/route.test.ts +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -12,6 +12,9 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { + // v1.37.0 — the route reads the day total before the upsert so the + // audit row can name what a `set` replaced (C4). + findUnique: vi.fn(), upsert: vi.fn(), update: vi.fn(), }, @@ -101,6 +104,11 @@ beforeEach(() => { remaining: 59, resetAt: Date.now() + 60_000, }); + // No stored row unless a test says otherwise; the pre-image read is + // best-effort and a missing day is the ordinary first write. + vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue( + null as never, + ); vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({ day: "2026-07-16", nutrient: "water", diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts index 1ac6e63fa..c3a3fc70c 100644 --- a/src/app/api/nutrients/water/route.ts +++ b/src/app/api/nutrients/water/route.ts @@ -17,9 +17,10 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiError, apiSuccess, @@ -53,13 +54,18 @@ function isRealCalendarDay(day: string): boolean { export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater)); async function postWater(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.37.0 — MANAGE. Hydration is a day TOTAL with no per-entry ledger, so a + // `set` overwrites the day with nothing behind it; C4 on the audit row is + // what makes the previous total readable back. + const { user, actor } = await requireRecordAuth("manage", "measurements"); const gate = await requireModuleEnabled(user.id, "nutrients"); if (!gate.enabled) return gate.response; + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own allowance + // and cannot collect a fresh one by switching records. const rl = await checkRateLimit( - `nutrients:water:${user.id}`, + `nutrients:water:${actor.id}`, WRITE_RATE_LIMIT_MAX, WRITE_RATE_LIMIT_WINDOW_MS, ); @@ -103,6 +109,17 @@ async function postWater(request: NextRequest): Promise { }, }; + // v1.37.0 — the day total as this request found it, for the audit row (C4). + // Read rather than derived: the upsert below is atomic and deliberately does + // not read first, so this is a separate, best-effort pre-image. A concurrent + // write can move the true total between the two statements; what the row + // says is what this caller replaced as far as this request could see, which + // is the honest claim and the one the feed needs. + const before = await prisma.nutrientIntakeDay.findUnique({ + where: key, + select: { amount: true }, + }); + let row = await prisma.nutrientIntakeDay.upsert({ where: key, create: { @@ -145,7 +162,18 @@ async function postWater(request: NextRequest): Promise { await auditLog("nutrient.water.write", { userId: user.id, ipAddress: getClientIp(request), - details: { day, mode, amountMl, resultingAmount: row.amount }, + // C4 — the day total that was there before. A `set` replaces it outright + // and no per-entry ledger exists to reconstruct it from. + details: { + day, + mode, + amountMl, + resultingAmount: row.amount, + ...overwriteDetails({ + before: { dayTotal: before?.amount ?? null }, + after: { dayTotal: row.amount }, + }), + }, }); annotate({ diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 524605f27..a7a5ec68b 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -360,7 +360,7 @@ describe("linking a document to an illness episode", () => { // intent and false of the route: the card's list query hit // `GET /api/documents/inbound`, which resolved the CALLER and 403'd, so // the card that "stays" rendered a query-error tile. That GET is - // delegable; `delegable-surface-guard.test.ts` freezes it and + // delegable; `sharing-surface-guard.test.ts` freezes it and // `sharing-delegable-routes.test.ts` drives it. expect(html).toContain('data-slot="episode-documents-card"'); } diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index f0d2ecb7c..53219288d 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -30,7 +30,7 @@ import { * no reachable caller. Logging a value on a tracked metric left for the same * reason one release later: its only form sits on `/custom-metrics/{id}`, * which a switch closes too. - * `delegable-surface-guard.test.ts` carries that argument + * `sharing-surface-guard.test.ts` carries that argument * in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and * bulk-acting stay with the owner, including on an entry the delegate added * themselves a minute ago, and so does every create the list does not name diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index ed0d85de5..8805c10b4 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -780,7 +780,7 @@ export interface RecordAuthContext extends AuthContext { * 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 + * `src/__tests__/sharing-surface-guard.test.ts` and reviewed against the * design's clustering table; this function only enforces what was declared. */ export async function requireRecordAuth( @@ -934,6 +934,16 @@ async function resolveSwitchedRecord( if (refusal !== null) throw denied(refusal); getEvent()?.setActingAs(owner.id); + // v1.37.0 — and whether this request may spend the owner's provider budget. + // MANAGE opens the generated reads; it does not open the generation behind + // them, because the owner would pay for and consent to an egress they did + // not cause. A managed profile is the exception and the reason the marker is + // read here rather than the grant: it has no self to protect from its own + // guardian. Stamped where the owner row is already loaded, so no generator + // has to be told; see `src/lib/sharing/delegated-generation.ts`. + if (owner.id !== auth.user.id && owner.managedProfileAt === null) { + getEvent()?.setDelegatedGenerationSuppressed(); + } // Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait // on the bookkeeping, and the bookkeeping failing must not fail the read. void touchGrantUsage(grant.id); diff --git a/src/lib/illness/day-log-write.ts b/src/lib/illness/day-log-write.ts index 6a1f01526..996c1cb80 100644 --- a/src/lib/illness/day-log-write.ts +++ b/src/lib/illness/day-log-write.ts @@ -26,6 +26,21 @@ export interface IllnessDayLogWriteResult { existed: boolean; /** The full DTO of the written row — no second round-trip needed. */ dto: IllnessDayLogDTO; + /** + * v1.37.0 — what this write destroyed, for the caller's audit row. + * + * The upsert is a rewrite wearing a create's verb: its UPDATE branch forces + * `deletedAt: null`, so it revives a day the owner deliberately deleted, and + * the symptom write is a full replacement of that day's links. Under MANAGE + * somebody other than the owner can do both, so the two facts that would + * otherwise vanish silently travel back with the result. + */ + replaced: { + /** Symptom keys the day carried before this write, sorted. */ + previousSymptomKeys: string[]; + /** True when the write brought a tombstoned day back. */ + revived: boolean; + }; } /** A symptom selection carrying its catalog key + optional 0–3 severity. */ @@ -105,7 +120,7 @@ export async function upsertIllnessDayLog( // run in ONE interactive transaction: no torn state between the link delete // + create halves, and no second round-trip — the written row (with its // flattened symptom links) comes straight back as the DTO. - const { row, existed } = await prisma.$transaction(async (tx) => { + const { row, existed, replaced } = await prisma.$transaction(async (tx) => { const existing = await tx.illnessDayLog.findUnique({ where, select: { @@ -113,6 +128,11 @@ export async function upsertIllnessDayLog( functionalImpact: true, feverC: true, noteEncrypted: true, + // v1.37.0 — the two facts the caller's audit row needs. Read inside + // the same transaction as the write, so what is reported is what was + // actually replaced rather than what a second read happened to see. + deletedAt: true, + symptomLinks: { select: { symptom: { select: { key: true } } } }, }, }); @@ -171,8 +191,17 @@ export async function upsertIllnessDayLog( include: dayLogSymptomInclude, }); - return { row: full, existed: existing !== null }; + return { + row: full, + existed: existing !== null, + replaced: { + previousSymptomKeys: (existing?.symptomLinks ?? []) + .map((link) => link.symptom.key) + .sort(), + revived: existing?.deletedAt != null, + }, + }; }); - return { id: row.id, existed, dto: toIllnessDayLogDTO(row) }; + return { id: row.id, existed, dto: toIllnessDayLogDTO(row), replaced }; } diff --git a/src/lib/insights/__tests__/status-cache-delegated.test.ts b/src/lib/insights/__tests__/status-cache-delegated.test.ts new file mode 100644 index 000000000..45afb40fe --- /dev/null +++ b/src/lib/insights/__tests__/status-cache-delegated.test.ts @@ -0,0 +1,109 @@ +/** + * v1.37.0 — C5 at the choke point: a delegated read enqueues no generation. + * + * The MANAGE level opens the ten generated reads. Nine of them reach the + * provider through `resolveReadOnlyStatusMiss`, one hop behind a GET that + * looks like a cache read, so a manager's first visit to a metric page used to + * ship the owner's health data to the owner's provider on the owner's budget + * under the owner's consent receipt — with nothing in the response saying so. + * + * The suppression is implemented once, here, rather than in the ten generators + * that call this function, so this file is where it has to be pinned. Both + * directions, because either one alone is worthless: without the positive + * control a broken function that never enqueues anything passes, and without + * the negative one the condition is not tested at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const enqueueStatusGeneration = vi.fn(async () => {}); +const hasUsableStatusProvider = vi.fn(async () => true); +const statusConsentBlocksGeneration = vi.fn(async () => false); +const suppressed = vi.fn(() => false); + +vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ + enqueueStatusGeneration: (...args: unknown[]) => + enqueueStatusGeneration(...(args as [])), + STATUS_GENERATE_QUEUE: "insight-status-generate", +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + hasUsableStatusProvider: (...args: unknown[]) => + hasUsableStatusProvider(...(args as [])), + statusConsentBlocksGeneration: (...args: unknown[]) => + statusConsentBlocksGeneration(...(args as [])), +})); + +vi.mock("@/lib/sharing/delegated-generation", () => ({ + delegatedGenerationSuppressed: () => suppressed(), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/db", () => ({ + prisma: { + // The last-good text and the negative-cache probe both read the audit + // ledger; an empty ledger is the cold-cache case this function is for. + auditLog: { + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => null), + }, + }, +})); + +import { resolveReadOnlyStatusMiss } from "@/lib/insights/status-cache"; + +beforeEach(() => { + // Every counter, not only the enqueue: the ordering assertion below reads + // the provider probe's call count, and a counter carried over from the + // previous test would make it fail for the wrong reason (or pass for one). + enqueueStatusGeneration.mockClear(); + hasUsableStatusProvider.mockClear(); + statusConsentBlocksGeneration.mockClear(); + suppressed.mockReturnValue(false); + hasUsableStatusProvider.mockResolvedValue(true); + statusConsentBlocksGeneration.mockResolvedValue(false); +}); + +const ARGS = { + userId: "owner-1", + metric: "WEIGHT" as never, + locale: "en" as never, +}; + +describe("resolveReadOnlyStatusMiss — the delegated path", () => { + it("enqueues a generation for the person whose record it is", async () => { + // The positive control. If this stops holding, the assertion below stops + // meaning anything: "nothing was enqueued" would be true of a function + // that never enqueues. + const outcome = await resolveReadOnlyStatusMiss(ARGS); + + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); + expect(outcome.kind).toBe("preparing"); + }); + + it("enqueues nothing while somebody else is holding the request", async () => { + suppressed.mockReturnValue(true); + + const outcome = await resolveReadOnlyStatusMiss(ARGS); + + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + // Still served, not refused: the read is admitted at MANAGE and only the + // generation behind it is withheld. + expect(outcome.kind).toBe("preparing"); + if (outcome.kind === "preparing") { + expect(outcome.revalidating).toBe(false); + } + }); + + it("decides before it asks whether a provider exists", async () => { + // Ordering matters for the money question, not only for the enqueue: the + // provider probe reads the owner's configuration, and a suppressed + // request has no business touching it. + suppressed.mockReturnValue(true); + + await resolveReadOnlyStatusMiss(ARGS); + + expect(hasUsableStatusProvider).not.toHaveBeenCalled(); + expect(statusConsentBlocksGeneration).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 7c629f6c2..6d6408d67 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -16,6 +16,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { MAX_CUSTOM_CORRELATION_CHANNELS } from "@/lib/insights/correlation-channel-series"; import { annotate } from "@/lib/logging/context"; +import { delegatedGenerationSuppressed } from "@/lib/sharing/delegated-generation"; import type { SupportedLocale } from "@/lib/insights/status-shared"; /** @@ -559,6 +560,30 @@ export async function resolveReadOnlyStatusMiss(args: { metric: InsightStatusScope; locale: SupportedLocale; }): Promise { + // v1.37.0 — the one gate on the delegated path, and it sits here rather + // than in the ten generators that reach this function precisely because it + // is one gate: a generator added next year inherits it without knowing it + // exists. A manager holding a MANAGE grant may READ the record's generated + // assessments; the miss behind that read must not ship the owner's data to + // the owner's provider on the owner's budget. Served the same way the + // negative-cache branch below serves it: last-good text if there is any, + // `preparing` if there is not, and nothing enqueued either way. + if (delegatedGenerationSuppressed()) { + const lastGood = await readLastGoodStatusText({ + userId: args.userId, + cacheAction: statusCacheAction(args.metric, args.locale), + }); + annotate({ + action: { name: "insights.status.preparing" }, + meta: { + metric: args.metric, + suppressed_enqueue: true, + delegated: true, + }, + }); + return { kind: "preparing", lastGood, revalidating: false }; + } + const hasProvider = await hasUsableStatusProvider(args.userId); if (!hasProvider) return { kind: "no-provider" }; diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 63b49d483..528a3c6e3 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -102,6 +102,21 @@ export class WideEventBuilder { return this; } + /** + * v1.37.0 — record that provider generation is closed on this request. + * + * Merges for the same reason `setActingAs` does, and is stamped in the same + * place: the record resolver, which is where the owner row is in hand. It is + * read back through `delegatedGenerationSuppressed()`. + */ + setDelegatedGenerationSuppressed(): this { + this.event.auth = { + ...(this.event.auth ?? {}), + delegated_generation: "suppressed", + }; + return this; + } + setAction(action: WideEvent["action"]): this { this.event.action = action; return this; diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index f9e77fbee..add4eeaa2 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -66,6 +66,16 @@ export interface WideEvent { * dashboard that conflates them cannot answer either. */ acting_as?: string; + /** + * v1.37.0 — set when this request is acting on somebody else's record and + * that somebody could have asked for the generation themselves. It is the + * one fact the generated reads consult before enqueuing a provider job: + * a manager's navigation must not spend the owner's budget or ship the + * owner's record to the owner's provider. Absent means "generate as + * usual", which is what every self-request, every guardian request on a + * managed profile and every background job is. + */ + delegated_generation?: "suppressed"; }; // Business-Daten (was wurde getan) diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index f00106075..08e76c502 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -59,8 +59,15 @@ export interface ApplyCanonicalSlotWriteInput { * the worker-minted pending row like every other write, and a fresh * create keeps the historical reminder provenance. `APPLE_HEALTH` * (v1.28) is the bulk route's HealthKit dose-event import. + * + * v1.37.0 — `IMPORT` joined the union because the per-event edit route + * re-creates a moved dose and must carry the ORIGINAL row's provenance onto + * the converged row (C8). A dose that came in through a history import and + * had its time corrected is still an imported dose; stamping it `WEB` + * because a human moved it is a small lie the compliance history then + * repeats forever. */ - createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH"; + createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH" | "IMPORT"; /** * v1.36.x — refuse a write that would change an outcome the owner has * already recorded, instead of applying it last-write-wins. diff --git a/src/lib/sharing/audit-details.ts b/src/lib/sharing/audit-details.ts new file mode 100644 index 000000000..d5c993365 --- /dev/null +++ b/src/lib/sharing/audit-details.ts @@ -0,0 +1,127 @@ +/** + * v1.37.0 — the two audit-detail shapes MANAGE is admitted on. + * + * The rule the level ships under is "reconstructable or refused": a manager + * may change and remove things in somebody else's record, and the owner must + * be able to read out of their own activity feed what went missing. The tree + * did not carry that. Every edit filed an id and nothing else — `measurement. + * update` said `{ measurementId }`, `medication.update` said `{ medicationId }` + * — so the feed could say a reading changed and could not say from what, and + * the hard deletes filed the id of a row that no longer exists. + * + * These two helpers are what the admitted verbs use instead. They are small on + * purpose: the audit table is not a second copy of the record and must not + * become one. Three columns, not thirty, and never a decrypted note. + * + * Both return a plain object meant to be SPREAD into a `details` literal at + * the call site rather than to replace it. The literal keeps naming the row's + * own id in the reviewer's line of sight, which is what the structural guard + * reads, and the spread carries the part that is tedious to write by hand and + * easy to get wrong. + */ + +/** + * What may enter an audit row. + * + * A closed union rather than `unknown`, because the failure it prevents is the + * one that costs the most: `details: { previous: existing }` is one keystroke + * from `details: { previous: { …, notesEncrypted } }`, and an encrypted note + * copied into the audit table is PHI with a second retention policy and no + * rotation story. A caller with a value outside this union has to decide what + * the feed should say about it, which is the decision this type exists to + * force. + */ +export type AuditScalar = string | number | boolean | Date | null | undefined; + +/** The longest string that lands verbatim; anything longer is trimmed. */ +const MAX_VALUE_LENGTH = 120; + +function coerce(value: AuditScalar): string | number | boolean | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string") { + return value.length > MAX_VALUE_LENGTH + ? `${value.slice(0, MAX_VALUE_LENGTH)}…` + : value; + } + return value; +} + +function same(a: AuditScalar, b: AuditScalar): boolean { + const left = a instanceof Date ? a.getTime() : (a ?? null); + const right = b instanceof Date ? b.getTime() : (b ?? null); + return left === right; +} + +/** + * **C4** — what an overwrite replaced. + * + * `fields` names the family the write touched, so the owner's feed can say + * "changed the value and the time" rather than "changed a reading". + * `previous` carries the values that are gone, for the fields where a handful + * of scalars is the whole fact. + * + * @param before the pre-image, caller-picked field by field. Never the row + * object: picking is the review step, and a spread of the row is how an + * encrypted column gets into an audit table. + * @param after the post-image over the same keys. Fields whose value did not + * move are left out of both halves — an edit that renamed nothing should not + * file a line saying it did. + * @param redacted fields that changed and whose values must never be written: + * free text, anything encrypted at rest. They appear in `fields` and never + * in `previous`, which is the honest answer — the feed says the note was + * rewritten and does not say what it said. + */ +export function overwriteDetails(args: { + before: Record; + after: Record; + redacted?: readonly string[]; +}): { + fields: string[]; + previous: Record; +} { + const previous: Record = {}; + const fields: string[] = []; + + for (const key of Object.keys(args.before)) { + if (same(args.before[key], args.after[key])) continue; + fields.push(key); + previous[key] = coerce(args.before[key]); + } + for (const key of args.redacted ?? []) { + if (!fields.includes(key)) fields.push(key); + } + + return { fields: fields.sort(), previous }; +} + +/** + * **C3** — what a hard delete destroyed. + * + * For the verbs whose domain does not tombstone, where the row is gone the + * moment the transaction commits and the audit row is the only thing left. It + * names the model, the id, a human label and the date the thing was about, + * which is the difference between "a preventive-care reminder was deleted" and + * "the colonoscopy recall for next March was deleted". + * + * `extra` is for the one or two domain facts a label cannot carry — a + * severity, a dose, a count. Scalars only, same reasoning as above. + */ +export function destroyedDetails(args: { + model: string; + id: string; + label: AuditScalar; + effectiveAt: AuditScalar; + extra?: Record; +}): Record { + const details: Record = { + destroyedModel: args.model, + destroyedId: args.id, + destroyedLabel: coerce(args.label), + destroyedAt: coerce(args.effectiveAt), + }; + for (const [key, value] of Object.entries(args.extra ?? {})) { + details[key] = coerce(value); + } + return details; +} diff --git a/src/lib/sharing/delegated-generation.ts b/src/lib/sharing/delegated-generation.ts new file mode 100644 index 000000000..eedb32094 --- /dev/null +++ b/src/lib/sharing/delegated-generation.ts @@ -0,0 +1,34 @@ +import { getEvent } from "@/lib/logging/context"; + +/** + * v1.37.0 — whether this request may spend the record owner's provider budget. + * + * MANAGE opens the generated surfaces: a manager opens a metric page and reads + * the assessment that is already there. What it must not open is the hop + * behind that read. Nine of the ten generated reads enqueue a generation job + * on a cache miss (`resolveReadOnlyStatusMiss`) and the narrative enqueues one + * whenever the row is stale, so on a delegated request the sequence is: the + * manager navigates, the owner's health data leaves the box under the owner's + * consent receipt, against the owner's provider, on the owner's budget, and + * the owner sees a bill line for something they did not do. The manager sees a + * card. Nothing on the wire says the two are the same event. + * + * So the read stays and the enqueue goes, on exactly one condition: somebody + * else is holding the request and the record has an owner who could have + * pressed the button themselves. A managed profile has nobody in that + * position — its guardian IS the person who decides what it generates — so the + * suppression does not apply there, and the guardian surfaces that trigger + * generation deliberately are its own list. + * + * Read from the request context rather than from an argument, the same + * reasoning `actingActorFor` states in `src/lib/auth/audit.ts`: the fact is + * settled once, in the resolver, where the owner row is in hand — and threading + * it through ten generator modules would be a second answer to "who is asking", + * with the routes that forgot to pass it being the ones nobody reviewed. A + * caller with no request context (every worker, every cron pass) reads false + * and generates as it always did, which is the correct answer for work nobody + * navigated into. + */ +export function delegatedGenerationSuppressed(): boolean { + return getEvent()?.getAuth()?.delegated_generation === "suppressed"; +} diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index 9139b7bf0..ca7093c26 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -6,7 +6,7 @@ * 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`. + * `src/__tests__/sharing-surface-guard.test.ts`. * * Two properties are the whole reason the file exists separately. * diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 3446cbcc1..8e7c44c92 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1769,7 +1769,7 @@ describe("GET /api/medications/compliance — the bucket follows the actor", () * record, and reading it is much of the point of handing somebody the record. * * Five GET arms moved. The upload, the retype, the delete, the episode link - * and every AI verb did not, and `delegable-surface-guard.test.ts` is what + * and every AI verb did not, and `sharing-surface-guard.test.ts` is what * freezes that split — this file asserts the moved arms actually substitute. */ async function enableDocuments(userId: string, enabled = true) { diff --git a/tests/integration/sharing-delegable-writes.test.ts b/tests/integration/sharing-delegable-writes.test.ts index 7ae07f56d..97dcb51de 100644 --- a/tests/integration/sharing-delegable-writes.test.ts +++ b/tests/integration/sharing-delegable-writes.test.ts @@ -448,7 +448,7 @@ writeContract("POST /api/biomarkers", { * have noticed. * * Re-admitting means flipping `requireAuth()` back to `requireRecordAuth`, - * re-listing the route in `delegable-surface-guard.test.ts`, and replacing this + * re-listing the route in `sharing-surface-guard.test.ts`, and replacing this * block with a `writeContract` — in the same diff as the caregiver-reachable * surface that made it worth doing. */ diff --git a/tests/integration/sharing-manage-perimeter.test.ts b/tests/integration/sharing-manage-perimeter.test.ts new file mode 100644 index 000000000..ea9bfbd61 --- /dev/null +++ b/tests/integration/sharing-manage-perimeter.test.ts @@ -0,0 +1,719 @@ +/** + * v1.37.0 — the MANAGE perimeter, driven as routes against real Postgres. + * + * MANAGE is the level where a delegate stops adding to somebody's record and + * starts changing it. Seventy modules answer at it; forty-three of them can + * destroy or rewrite something. The structural guard freezes WHICH routes say + * `"manage"` and, since this release, freezes the conditions each admission + * was granted on — but a frozen tag is a promise about code, not about + * behaviour, and the promises here are the kind that fail quietly: an audit + * row that names an id and nothing else, a rate bucket keyed on the wrong + * person, a navigation that spends the owner's AI budget. + * + * So this file drives the shipped exports. Same construction as + * `sharing-delegable-writes.test.ts`: the real `apiHandler`, the real + * `requireRecordAuth`, the real grant table, the real Prisma writes. The grant + * is minted through `inviteGrant` + `acceptGrant` because "a MANAGE grant + * exists" and "a MANAGE grant can be created by the flow this release ships" + * are different claims. + * + * Four legs per verb, then the conditions: + * + * 1. a manager with a live MANAGE grant changes the OWNER's record, and the + * audit row carries `userId = owner` AND `actorUserId = manager`; + * 2. the same person under a WRITE grant is refused, and nothing moved — + * asserted by re-reading the row, not by the status alone; + * 3. a caller who was never granted anything is refused the same way; + * 4. a caller who has not switched gets exactly what they always got. + * + * The conditions each get their own test, and each is written so that removing + * the implementation turns it red rather than leaving it green with a smaller + * claim: the reconstruction legs read the audit `details` and assert the + * VALUES, the scope leg reads the rate-limit table and asserts the key, and + * the generation leg asserts both halves — nothing enqueued for the manager, + * something enqueued for the owner doing the identical thing. + */ +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"; + +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), +})); + +/** + * The narrative warm, stubbed at the seam the condition is about. + * + * C5 is the condition that costs the owner money when it is wrong, and what it + * suppresses is an ENQUEUE — a pg-boss insert whose absence is invisible from + * the response. Counting the calls is the only way to see it, and the count + * has to be taken on both sides: zero for the manager is worth nothing unless + * the same request by the owner is one. + */ +const enqueueNarrativeWarm = vi.fn(async (_payload: unknown) => {}); +vi.mock("@/lib/jobs/period-narrative-shared", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + enqueueNarrativeWarm: (payload: unknown) => enqueueNarrativeWarm(payload), + }; +}); + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `mg-${suffix}`, + email: `mg-${suffix}@example.test`, + displayName: `MG ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** A live grant at the named level, accepted, the caller switched into it. */ +async function switchInto( + ownerId: string, + delegateId: string, + access: "READ" | "WRITE" | "MANAGE", +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access, + scope: null, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: delegateId, + }); + expect(grant.access).toBe(access); + expect(grant.acceptedAt).not.toBeNull(); + + const session = await signIn(delegateId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); + return { grant, session }; +} + +/** A session inside a record nobody ever shared with this caller. */ +async function claimWithoutGrant(ownerId: string, callerId: string) { + const session = await signIn(callerId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +function request(method: string, url: string, body?: unknown): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function call( + handler: Handler, + method: string, + url: string, + body?: unknown, + params: Record = {}, +): Promise { + return handler(request(method, url, body), { + params: Promise.resolve(params), + }); +} + +async function payload(response: Response): Promise<{ + data: unknown; + error: unknown; + meta?: { errorCode?: string }; +}> { + return response.json(); +} + +/** The stable refusal, asserted by code rather than by status alone. */ +async function expectDenied(response: Response) { + expect(response.status).toBe(403); + expect((await payload(response)).meta?.errorCode).toBe( + "sharing.access.denied", + ); +} + +/** The audit row a verb filed, with its details already parsed. */ +async function auditRow(action: string) { + const row = await getPrismaClient().auditLog.findFirst({ + where: { action }, + orderBy: { createdAt: "desc" }, + }); + expect(row, `no ${action} row was written`).not.toBeNull(); + return { + row: row!, + details: JSON.parse(row!.details ?? "{}") as Record, + }; +} + +const ISO = "2026-07-15T08:00:00.000Z"; + +/* -------------------------------------------------------------------------- */ +/* The shape of a MANAGE case */ +/* -------------------------------------------------------------------------- */ + +interface ManageCase { + /** Rows the verb acts on, created under the record it will change. */ + prepare: (recordOwnerId: string) => Promise; + /** Drive the shipped export against the current session. */ + act: (ctx: C) => Promise; + /** The status a successful call answers with. */ + ok: number; + /** The action the happy path files through `auditLog`. */ + auditAction: string; + /** True once the verb has taken effect on the prepared rows. */ + applied: (ctx: C) => Promise; +} + +function manageContract(name: string, c: ManageCase) { + describe(name, () => { + it("changes the owner's record and names the manager as actor", async () => { + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const ctx = await c.prepare(owner.id); + + await switchInto(owner.id, manager.id, "MANAGE"); + const response = await c.act(ctx); + expect(response.status).toBe(c.ok); + + // The change landed… + expect(await c.applied(ctx)).toBe(true); + // …and the trail says who made it, under whose record. + const { row } = await auditRow(c.auditAction); + expect(row.userId).toBe(owner.id); + expect(row.actorUserId).toBe(manager.id); + }); + + it("refuses a WRITE grant, and nothing moves", async () => { + const owner = await makeUser("owner"); + const helper = await makeUser("helper"); + const ctx = await c.prepare(owner.id); + + await switchInto(owner.id, helper.id, "WRITE"); + await expectDenied(await c.act(ctx)); + + // The mutation-check rule: assert it did NOT apply, by re-reading. + expect(await c.applied(ctx)).toBe(false); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + const ctx = await c.prepare(owner.id); + + await claimWithoutGrant(owner.id, stranger.id); + await expectDenied(await c.act(ctx)); + expect(await c.applied(ctx)).toBe(false); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + const ctx = await c.prepare(plain.id); + await signIn(plain.id); + + expect((await c.act(ctx)).status).toBe(c.ok); + expect(await c.applied(ctx)).toBe(true); + + const { row } = await auditRow(c.auditAction); + expect(row.userId).toBe(plain.id); + expect(row.actorUserId).toBeNull(); + }); + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + enqueueNarrativeWarm.mockClear(); +}); + +/* -------------------------------------------------------------------------- */ +/* Fixtures */ +/* -------------------------------------------------------------------------- */ + +async function makeMeasurement(userId: string, value = 80) { + // The timestamp moves per row: `(userId, type, measuredAt, source, + // sleepStage)` is unique, so two fixtures on the same instant collide. + const measuredAt = new Date(Date.parse(ISO) + counter++ * 60_000); + return getPrismaClient().measurement.create({ + data: { + userId, + type: "WEIGHT", + value, + unit: "kg", + measuredAt, + source: "MANUAL", + }, + }); +} + +async function makeLabResult(userId: string) { + return getPrismaClient().labResult.create({ + data: { + userId, + analyte: "HbA1c", + value: 5.2, + unit: "%", + takenAt: new Date(ISO), + }, + }); +} + +async function makeAllergy(userId: string) { + return getPrismaClient().allergy.create({ + data: { + userId, + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: new Date("2020-03-01T00:00:00.000Z"), + }, + }); +} + +async function makeReminder(userId: string) { + return getPrismaClient().measurementReminder.create({ + data: { + userId, + label: "Colonoscopy recall", + measurementType: null, + intervalDays: 365, + notifyHour: 9, + nextDueAt: new Date("2027-03-01T08:00:00.000Z"), + enabled: true, + }, + }); +} + +/* -------------------------------------------------------------------------- */ +/* 1 — the four legs, one verb per domain family */ +/* -------------------------------------------------------------------------- */ + +manageContract("PUT /api/measurements/[id]", { + prepare: (ownerId) => makeMeasurement(ownerId), + act: async (row) => { + const { PUT } = await import("@/app/api/measurements/[id]/route"); + return call( + PUT as Handler, + "PUT", + `/api/measurements/${row.id}`, + { + value: 74.5, + }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "measurement.update", + applied: async (row) => { + const after = await getPrismaClient().measurement.findUnique({ + where: { id: row.id }, + }); + return after?.value === 74.5; + }, +}); + +manageContract("DELETE /api/measurements/[id]", { + prepare: (ownerId) => makeMeasurement(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/measurements/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/measurements/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "measurement.delete", + applied: async (row) => { + const after = await getPrismaClient().measurement.findUnique({ + where: { id: row.id }, + }); + return after?.deletedAt !== null; + }, +}); + +manageContract("PUT /api/labs/[id]", { + prepare: (ownerId) => makeLabResult(ownerId), + act: async (row) => { + const { PUT } = await import("@/app/api/labs/[id]/route"); + return call( + PUT as Handler, + "PUT", + `/api/labs/${row.id}`, + { value: 6.1 }, + { + id: row.id, + }, + ); + }, + ok: 200, + auditAction: "labResult.update", + applied: async (row) => { + const after = await getPrismaClient().labResult.findUnique({ + where: { id: row.id }, + }); + return after?.value === 6.1; + }, +}); + +manageContract("PATCH /api/allergies/[id]", { + prepare: (ownerId) => makeAllergy(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/allergies/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/allergies/${row.id}`, + { severity: "MILD" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "allergy.update", + applied: async (row) => { + const after = await getPrismaClient().allergy.findUnique({ + where: { id: row.id }, + }); + return after?.severity === "MILD"; + }, +}); + +manageContract("DELETE /api/measurement-reminders/[id]", { + prepare: (ownerId) => makeReminder(ownerId), + act: async (row) => { + const { DELETE } = + await import("@/app/api/measurement-reminders/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/measurement-reminders/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "measurementReminder.delete", + applied: async (row) => { + const after = await getPrismaClient().measurementReminder.findUnique({ + where: { id: row.id }, + }); + return after === null; + }, +}); + +/* -------------------------------------------------------------------------- */ +/* 2 — reconstructable or refused */ +/* -------------------------------------------------------------------------- */ + +describe("what a destruction leaves behind", () => { + it("C3: a hard delete files what it destroyed, and never the encrypted text", async () => { + // The preventive-care reminder is the sharpest case in the tree: the row + // is gone, no restore route reaches it, and the audit row used to say + // `{ reminderId }` — a pointer at nothing. An owner reading their feed + // could see that a recall disappeared and never learn what it was for. + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const reminder = await makeReminder(owner.id); + + await switchInto(owner.id, manager.id, "MANAGE"); + const { DELETE } = + await import("@/app/api/measurement-reminders/[id]/route"); + expect( + ( + await call( + DELETE as Handler, + "DELETE", + `/api/measurement-reminders/${reminder.id}`, + undefined, + { id: reminder.id }, + ) + ).status, + ).toBe(200); + + const { details } = await auditRow("measurementReminder.delete"); + expect(details.destroyedModel).toBe("MeasurementReminder"); + expect(details.destroyedId).toBe(reminder.id); + expect(details.destroyedLabel).toBe("Colonoscopy recall"); + expect(details.destroyedAt).toBe(reminder.nextDueAt?.toISOString()); + expect(details.intervalDays).toBe(365); + }); + + it("C3: the allergy delete carries the allergen and never the reaction", async () => { + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const allergy = await getPrismaClient().allergy.create({ + data: { + userId: owner.id, + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: new Date("2020-03-01T00:00:00.000Z"), + }, + }); + + await switchInto(owner.id, manager.id, "MANAGE"); + const { DELETE } = await import("@/app/api/allergies/[id]/route"); + expect( + ( + await call( + DELETE as Handler, + "DELETE", + `/api/allergies/${allergy.id}`, + undefined, + { id: allergy.id }, + ) + ).status, + ).toBe(200); + + const { row, details } = await auditRow("allergy.delete"); + expect(details.destroyedLabel).toBe("Penicillin"); + expect(details.severity).toBe("SEVERE"); + expect(details.destroyedAt).toBe("2020-03-01T00:00:00.000Z"); + // …and the audit table did not become a second store for the encrypted + // half of the row. Asserted on the raw string, because a nested key would + // slip past a top-level property check. + expect(row.details ?? "").not.toContain("reaction"); + }); + + it("C4: an edit files the field family and the value that is gone", async () => { + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const measurement = await makeMeasurement(owner.id, 80); + + await switchInto(owner.id, manager.id, "MANAGE"); + const { PUT } = await import("@/app/api/measurements/[id]/route"); + expect( + ( + await call( + PUT as Handler, + "PUT", + `/api/measurements/${measurement.id}`, + { value: 74.5 }, + { id: measurement.id }, + ) + ).status, + ).toBe(200); + + const { details } = await auditRow("measurement.update"); + expect(details.fields).toEqual(["value"]); + expect((details.previous as Record).value).toBe(80); + }); + + it("a restore puts back the row it names, and leaves the others tombstoned", async () => { + // The fake-that-ignores-its-`where` case: a count check passes on a + // restore that clears every tombstone in the record. The id is what + // defeats it. + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const kept = await makeMeasurement(owner.id, 81); + const other = await makeMeasurement(owner.id, 82); + await getPrismaClient().measurement.updateMany({ + where: { id: { in: [kept.id, other.id] } }, + data: { deletedAt: new Date() }, + }); + + await switchInto(owner.id, manager.id, "MANAGE"); + const { POST } = await import("@/app/api/measurements/restore/route"); + const response = await call( + POST as Handler, + "POST", + "/api/measurements/restore", + { + ids: [kept.id], + }, + ); + expect(response.status).toBe(200); + + const restored = await getPrismaClient().measurement.findUnique({ + where: { id: kept.id }, + }); + const untouched = await getPrismaClient().measurement.findUnique({ + where: { id: other.id }, + }); + expect(restored?.deletedAt).toBeNull(); + expect(untouched?.deletedAt).not.toBeNull(); + + const { row } = await auditRow("measurement.restore"); + expect(row.userId).toBe(owner.id); + expect(row.actorUserId).toBe(manager.id); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* 3 — the conditions that are not about the audit row */ +/* -------------------------------------------------------------------------- */ + +describe("the conditions the admissions were granted on", () => { + it("C1: the manager burns their own rate allowance, not the owner's", async () => { + // The buckets live in Postgres, so the key itself is readable. A bucket + // keyed on the record would let a manager lock the owner out of their own + // restore button by hammering it. + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const row = await makeMeasurement(owner.id); + await getPrismaClient().measurement.update({ + where: { id: row.id }, + data: { deletedAt: new Date() }, + }); + + await switchInto(owner.id, manager.id, "MANAGE"); + const { POST } = await import("@/app/api/measurements/restore/route"); + expect( + ( + await call(POST as Handler, "POST", "/api/measurements/restore", { + ids: [row.id], + }) + ).status, + ).toBe(200); + + const buckets = await getPrismaClient().rateLimit.findMany(); + expect(buckets.length).toBeGreaterThan(0); + expect(buckets.some((b) => b.key.includes(manager.id))).toBe(true); + expect(buckets.some((b) => b.key.includes(owner.id))).toBe(false); + }); + + it("C5: a manager's navigation enqueues no generation; the owner's does", async () => { + // Both halves, and the second is what makes the first mean anything: the + // narrative route warms unconditionally, so a zero on the delegated side + // could just as easily mean the route never reached the enqueue at all. + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const { GET } = await import("@/app/api/insights/narrative/route"); + + await signIn(owner.id); + expect( + (await call(GET as Handler, "GET", "/api/insights/narrative?period=week")) + .status, + ).toBe(200); + expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(1); + + enqueueNarrativeWarm.mockClear(); + + await switchInto(owner.id, manager.id, "MANAGE"); + const delegated = await call( + GET as Handler, + "GET", + "/api/insights/narrative?period=week", + ); + expect(delegated.status).toBe(200); + expect(enqueueNarrativeWarm).not.toHaveBeenCalled(); + }); + + it("C5: a WRITE grant cannot read the generated surfaces at all", async () => { + const owner = await makeUser("owner"); + const helper = await makeUser("helper"); + await switchInto(owner.id, helper.id, "WRITE"); + + const { GET } = await import("@/app/api/insights/narrative/route"); + await expectDenied( + await call(GET as Handler, "GET", "/api/insights/narrative?period=week"), + ); + expect(enqueueNarrativeWarm).not.toHaveBeenCalled(); + }); + + it("C6: the delegated mood create refuses the sync client's externalId", async () => { + const owner = await makeUser("owner"); + const manager = await makeUser("manager"); + const body = { + mood: "GUT", + moodLoggedAt: ISO, + // A stable, device-shaped id: the floor on `externalId` refuses an + // unstable one at the schema, and this test is about the level, not + // about that floor. + externalId: "8B0F3C2E-1D4A-4F5B-9C6D-7E8F90A1B2C3", + source: "MOODLOG", + }; + + await switchInto(owner.id, manager.id, "MANAGE"); + const { POST } = await import("@/app/api/mood-entries/route"); + const refused = await call( + POST as Handler, + "POST", + "/api/mood-entries", + body, + ); + expect(refused.status).toBe(422); + expect((await payload(refused)).meta?.errorCode).toBe( + "mood.create.external_id_not_delegable", + ); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }), + ).toBe(0); + + // The same body from the owner's own session still lands: C6 closes the + // delegated branch, not the route. + await signIn(owner.id); + const accepted = await call( + POST as Handler, + "POST", + "/api/mood-entries", + body, + ); + expect(accepted.status).toBe(201); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }), + ).toBe(1); + }); +});