From 255cbf5fe0ac5f711e40a1c2aec195425a51fd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:30:46 +0200 Subject: [PATCH 1/8] feat(sharing): open the measurement reads to a delegate Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the measurement list, the single reading, the two series reads, and the two reminder reads. Each is admitted in the delegable classification with its five answers written; each enters the frozen list with the argument for why a delegate reading through it cannot extend their own reach. Only the GET arms move. The POST, PUT and DELETE arms in the same files keep `requireAuth()`, which refuses outright while a switch is active, so a file entering the list admits a read and nothing else. The two validation breadcrumbs on the list and series reads now write through `auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the only writer that stamps `actorUserId`, and without the stamp a delegate's malformed query lands on the owner's record looking like the owner's own. Integration coverage drives the shipped GET exports against real Postgres: the owner's rows reach a delegate holding a live grant, the delegate's own rows do not, a caller without a grant is refused `sharing.access.denied`, the request after a revocation is refused with no re-login, and a caller who never switched sees exactly what they saw before. --- src/__tests__/delegable-surface-guard.test.ts | 29 +- .../api/measurement-reminders/[id]/route.ts | 4 +- src/app/api/measurement-reminders/route.ts | 4 +- src/app/api/measurements/[id]/route.ts | 4 +- .../__tests__/group-by-day.test.ts | 4 + .../__tests__/zod-multi-issue.test.ts | 28 +- src/app/api/measurements/route.ts | 25 +- .../api/measurements/series-batch/route.ts | 4 +- src/app/api/measurements/series/route.ts | 26 +- .../sharing-delegable-routes.test.ts | 474 ++++++++++++++++++ 10 files changed, 550 insertions(+), 52 deletions(-) create mode 100644 tests/integration/sharing-delegable-routes.test.ts diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 526090ab1..06bf3017f 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -234,16 +234,33 @@ function exportedFunctionNames(rel: string): string[] { /** * Route modules permitted to act on a record that may not be the caller's. * - * EMPTY, and correct empty. The resolver landed with the auth layer and - * deliberately touched no route file; the first members arrive when routes - * migrate, each one carrying a line in the classification record that says why - * it may. A route absent from that record cannot enter this list. + * Every member carries a line in the classification record that says why it + * may. A route absent from that record cannot enter this list. * * The value is the reason, in one line, in the maintainer's words. Not the * route's description — the argument for why a delegate reading or writing * through it cannot extend their own reach. + * + * Note what a member means: the MODULE reaches the resolver, and in every case + * below that is the GET arm alone. The write arms in these same files still + * resolve through `requireAuth()`, which refuses outright while a switch is + * active, so admitting a file here admits a read and nothing more. A delegable + * write is a separate decision that would have to be made at each of them. */ -const DELEGABLE_ROUTES: Record = {}; +const DELEGABLE_ROUTES: Record = { + "app/api/measurements/route.ts": + "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id` and offers no control affordance; the POST arms in the same file keep `requireAuth()` and so refuse under a switch.", + "app/api/measurements/[id]/route.ts": + "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", + "app/api/measurements/series/route.ts": + "A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.", + "app/api/measurements/series-batch/route.ts": + "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.", + "app/api/measurement-reminders/route.ts": + "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", + "app/api/measurement-reminders/[id]/route.ts": + "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", +}; /** * Route modules that deliberately resolve the ACTOR and keep working under a @@ -266,7 +283,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 1; +const FROZEN_ENTRY_COUNT = 7; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index e591c7481..76202e373 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/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, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, @@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const reminder = await prisma.measurementReminder.findFirst({ diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 7a05da314..71a813bb4 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, getClientIp, @@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const reminders = await prisma.measurementReminder.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 2537dc58a..5862b3957 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; diff --git a/src/app/api/measurements/__tests__/group-by-day.test.ts b/src/app/api/measurements/__tests__/group-by-day.test.ts index 9255e6719..4f26f7c23 100644 --- a/src/app/api/measurements/__tests__/group-by-day.test.ts +++ b/src/app/api/measurements/__tests__/group-by-day.test.ts @@ -54,6 +54,7 @@ vi.mock("next/headers", () => ({ })); import { GET } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -80,6 +81,9 @@ beforeEach(() => { // write; the route swallows rejections so the test only needs a // resolved mock to keep the catch-block silent. vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.0 — the 422 breadcrumb writes through the `auditLog` helper, whose + // returned promise the route attaches a `.catch` to. + vi.mocked(auditLog).mockResolvedValue(undefined); // v1.29.6 — null source-priority blob → default rank ladders for the // groupBy=day canonical-source collapse. vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index e086b323f..41c4a589a 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -30,9 +30,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -53,6 +51,7 @@ vi.mock("next/headers", () => ({ })); import { GET, POST } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { Prisma } from "@/generated/prisma/client"; @@ -71,6 +70,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper now, because that helper is the only writer that stamps + // `actorUserId`. `resetAllMocks` above clears the factory's implementation, + // so the resolved promise the route awaits has to be restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); }); function getReq(qs: string): NextRequest { @@ -121,18 +125,18 @@ describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => { const res = await GET(getReq("limit=999999&offset=-1")); 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; details: string }; - }; - expect(call.data.userId).toBe("user-1"); - expect(call.data.action).toBe("measurements.list.validation-failed"); + expect(auditLog).toHaveBeenCalledTimes(1); + // v1.36.0 — through the helper, which files the row under the resolved + // record and stamps the delegate as actor when there is one. The bare + // `prisma.auditLog.create` this replaced could not do the second half. + expect(auditLog).toHaveBeenCalledWith( + "measurements.list.validation-failed", + expect.objectContaining({ userId: "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 GET(getReq("limit=999999&offset=-1")); expect(res.status).toBe(422); }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6c97ba60b..24eda8253 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; @@ -58,7 +58,7 @@ import type { import { Prisma } from "@/generated/prisma/client"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMeasurementsSchema.safeParse(params); @@ -78,17 +78,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurements.list.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("measurements.list.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/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts index 15b57ae96..f447b24b6 100644 --- a/src/app/api/measurements/series-batch/route.ts +++ b/src/app/api/measurements/series-batch/route.ts @@ -22,7 +22,7 @@ * client keeps sleep on the dedicated route. */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { returnAllZodIssues } from "@/lib/api-response"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -33,7 +33,7 @@ import { } from "@/lib/validations/series-batch"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = seriesBatchQuerySchema.safeParse(params); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index bd79aa425..9233530a3 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -11,7 +11,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess, buildPayloadDiagnostic, @@ -159,7 +160,7 @@ function stdDev(values: number[]): number { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const rawQuery = Object.fromEntries(request.nextUrl.searchParams); const parsed = querySchema.safeParse(rawQuery); @@ -193,17 +194,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "measurements.series.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("measurements.series.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts new file mode 100644 index 000000000..c8d437e45 --- /dev/null +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -0,0 +1,474 @@ +/** + * The migrated routes, driven as routes, against real Postgres. + * + * Every case below imports the shipped `GET` export and calls it. Nothing is + * reimplemented: the real `apiHandler`, the real `requireRecordAuth`, the real + * grant table, the real Prisma queries. That matters more here than anywhere + * else in this feature, because the thing being asserted is a substitution + * that happens ABOVE the handler body — a test that rebuilt the handler would + * be testing its own copy of the substitution and would keep passing after the + * route stopped performing it. + * + * Four questions per domain, and the fourth is the one that decides whether + * this release is safe to ship: + * + * 1. a delegate with a live READ grant reads the OWNER's rows; + * 2. the same delegate without a grant is refused `sharing.access.denied`; + * 3. after revocation the very NEXT request is refused — no re-login; + * 4. a caller who never switched gets exactly what they got before. + * + * (4) is the regression. Forty-four routes changed; a person who shares + * nothing must not be able to tell. + * + * Owner and delegate are BOTH seeded with data in every case, and the + * assertions name the owner's values and the delegate's separately. An empty + * result satisfies "the delegate's rows are absent" while hiding the exact + * failure this feature exists to avoid. + */ +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), +})); + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `deleg-${suffix}`, + email: `deleg-${suffix}@example.test`, + displayName: `Deleg ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + }, + }); +} + +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 and a session already inside the owner's record. + * + * Written straight to the tables rather than driven through invite / accept / + * switch, because `sharing-lifecycle.test.ts` already proves those three + * routes produce exactly these rows and repeating the handshake once per + * domain would buy nothing but wall-clock. The rows are the contract; this + * file is about what the migrated routes do once they exist. + */ +async function switchInto(ownerId: string, delegateId: string) { + const grant = await getPrismaClient().accountGrant.create({ + data: { + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + acceptedAt: new Date(), + }, + }); + const session = await signIn(delegateId); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: ownerId }, + }); + return { grant, session }; +} + +/** The delegate is inside the record, but the grant is gone. */ +async function revokeGrant(grantId: string) { + await getPrismaClient().accountGrant.update({ + where: { id: grantId }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); +} + +/** + * A route's `GET` export, seen from outside. + * + * Each shipped handler declares its own params shape (`{ id: string }` and so + * on), which is exactly the sort of per-route difference a driver like this one + * has no business knowing. The cast at each call site narrows to this, and the + * `params` object handed in is checked by the route's own Zod / lookup logic + * one line later — a wrong id produces a 404 from the handler, not a type + * error here. + */ +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +function get(url: string): NextRequest { + return new NextRequest(`http://localhost${url}`, { method: "GET" }); +} + +async function call( + handler: Handler, + url: string, + params: Record = {}, +): Promise { + return handler(get(url), { params: Promise.resolve(params) }); +} + +async function body(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 body(response)).meta?.errorCode).toBe("sharing.access.denied"); +} + +/** + * The four-part contract, run against one route. + * + * `seed` writes one row for whichever user it is handed and returns the value + * that identifies it in the response; `read` pulls those identifying values + * back out of the response body. The domain suites below supply both, so each + * one is four assertions of substance rather than four of shape. + */ +function contract( + name: string, + route: { + call: () => Promise; + seed: (userId: string, marker: T) => Promise; + read: (payload: never) => T[]; + ownerMarker: T; + delegateMarker: T; + }, +) { + describe(name, () => { + it("serves the owner's rows to a delegate holding a live grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await route.seed(owner.id, route.ownerMarker); + await route.seed(delegate.id, route.delegateMarker); + + await switchInto(owner.id, delegate.id); + const response = await route.call(); + + expect(response.status).toBe(200); + const rows = route.read((await body(response)).data as never); + expect(rows).toContainEqual(route.ownerMarker); + // The delegate's own row is seeded and must be absent. Asserting the + // owner's presence alone would pass on a handler that returned + // everybody's rows. + expect(rows).not.toContainEqual(route.delegateMarker); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await route.seed(owner.id, route.ownerMarker); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + await expectDenied(await route.call()); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await route.seed(owner.id, route.ownerMarker); + + const { grant } = await switchInto(owner.id, delegate.id); + expect((await route.call()).status).toBe(200); + + // No sign-out, no new session, no cache flush. The same browser, the + // next request. + await revokeGrant(grant.id); + await expectDenied(await route.call()); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await route.seed(plain.id, route.ownerMarker); + await signIn(plain.id); + + const response = await route.call(); + expect(response.status).toBe(200); + expect(route.read((await body(response)).data as never)).toContainEqual( + route.ownerMarker, + ); + }); + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +/* -------------------------------------------------------------------------- */ +/* Measurements */ +/* -------------------------------------------------------------------------- */ + +async function seedWeight(userId: string, value: number) { + await getPrismaClient().measurement.create({ + data: { + userId, + type: "WEIGHT", + value, + unit: "kg", + measuredAt: new Date("2026-07-15T08:00:00Z"), + source: "MANUAL", + }, + }); +} + +contract("GET /api/measurements", { + call: async () => { + const { GET } = await import("@/app/api/measurements/route"); + return call(GET as Handler, "/api/measurements"); + }, + seed: seedWeight, + read: (payload: { measurements: { value: number }[] }) => + payload.measurements.map((m) => m.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +contract("GET /api/measurements/series", { + call: async () => { + const { GET } = await import("@/app/api/measurements/series/route"); + return call(GET as Handler, "/api/measurements/series?kind=weight&days=90"); + }, + seed: seedWeight, + read: (payload: { points: { value: number }[] }) => + payload.points.map((p) => p.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +contract("GET /api/measurements/series-batch", { + call: async () => { + const { GET } = await import("@/app/api/measurements/series-batch/route"); + return call( + GET as Handler, + "/api/measurements/series-batch?types=WEIGHT&from=2026-01-01T00:00:00Z&to=2026-12-31T00:00:00Z", + ); + }, + seed: seedWeight, + read: (payload: { series: Record }) => + (payload.series.WEIGHT ?? []).map((row) => row.value), + ownerMarker: 81, + delegateMarker: 64, +}); + +describe("GET /api/measurements/[id]", () => { + it("serves one of the owner's readings and refuses the delegate's reach past it", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedWeight(owner.id, 81); + await seedWeight(delegate.id, 64); + const ownerRow = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/measurements/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/measurements/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ value: 81 }); + + // The fetch-then-guard arm now guards against the OWNER. The delegate's + // own reading is out of scope while they are inside somebody else's + // record — which is the correct answer and a surprising one, so it is + // asserted rather than assumed. + const theirs = await call( + GET as Handler, + `/api/measurements/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/measurements/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedWeight(plain.id, 77); + const row = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/measurements/[id]/route"); + const response = await call(GET as Handler, `/api/measurements/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ value: 77 }); + }); +}); + +describe("the 422 breadcrumb a delegate leaves on the owner's record", () => { + it("files under the owner and names the delegate as the actor", async () => { + // The one write a delegable measurements GET performs. It was a bare + // `prisma.auditLog.create` before the migration, which files the row with + // `actorUserId` NULL — indistinguishable from the owner having typed the + // bad query themselves. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await switchInto(owner.id, delegate.id); + + const { GET } = await import("@/app/api/measurements/route"); + const refused = await call( + GET as Handler, + "/api/measurements?limit=not-a-number", + ); + expect(refused.status).toBe(422); + + await vi.waitFor(async () => { + const row = await getPrismaClient().auditLog.findFirst({ + where: { action: "measurements.list.validation-failed" }, + }); + expect(row?.userId).toBe(owner.id); + expect(row?.actorUserId).toBe(delegate.id); + }); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Measurement reminders */ +/* -------------------------------------------------------------------------- */ + +async function seedReminder(userId: string, label: string) { + await getPrismaClient().measurementReminder.create({ + data: { + userId, + label, + measurementType: "WEIGHT", + intervalDays: 7, + enabled: true, + }, + }); +} + +contract("GET /api/measurement-reminders", { + call: async () => { + const { GET } = await import("@/app/api/measurement-reminders/route"); + return call(GET as Handler, "/api/measurement-reminders"); + }, + seed: seedReminder, + read: (payload: { label: string }[]) => payload.map((r) => r.label), + ownerMarker: "owner weigh-in", + delegateMarker: "delegate weigh-in", +}); + +describe("GET /api/measurement-reminders/[id]", () => { + it("serves the owner's reminder and refuses once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedReminder(owner.id, "owner weigh-in"); + await seedReminder(delegate.id, "delegate weigh-in"); + const ownerRow = + await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = + await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/measurement-reminders/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ label: "owner weigh-in" }); + + const theirs = await call( + GET as Handler, + `/api/measurement-reminders/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/measurement-reminders/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedReminder(plain.id, "plain weigh-in"); + const row = await getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const response = await call( + GET as Handler, + `/api/measurement-reminders/${row.id}`, + { id: row.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + label: "plain weigh-in", + }); + }); +}); From af0f7497402d9656130876bcdb79e2be4b35c4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:38:39 +0200 Subject: [PATCH 2/8] feat(sharing): open the lab and clinical-record reads to a delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten routes: lab results and their detail, the biomarker catalogue and its detail, allergies, family history, the mental-health screener history, and the health-profile facts. All GET arms; every write arm in the same files keeps `requireAuth()` and refuses under a switch. The family-history payload is the one admitted read that carries third-party health information by design — it describes the owner's relatives — and it is admitted because a caregiver reading a family history is what the feature is for and the rows are the owner's own. The mental-health module gate resolves against the record, not the caller. That is the recorded decision: the module map says which parts of a record exist, so a delegate who enabled the screener on their own account gains no screener view inside somebody else's. The integration test asserts both directions. --- src/__tests__/delegable-surface-guard.test.ts | 22 +- src/app/api/allergies/[id]/route.ts | 4 +- src/app/api/allergies/route.ts | 4 +- .../anamnesis/facts/__tests__/route.test.ts | 7 + src/app/api/anamnesis/facts/route.ts | 4 +- src/app/api/biomarkers/[id]/route.ts | 4 +- src/app/api/biomarkers/route.ts | 4 +- src/app/api/family-history/[id]/route.ts | 4 +- src/app/api/family-history/route.ts | 4 +- src/app/api/labs/[id]/route.ts | 4 +- src/app/api/labs/route.ts | 4 +- .../api/mental-health/assessments/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 368 ++++++++++++++++++ 13 files changed, 416 insertions(+), 21 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 06bf3017f..f3ad80b47 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -260,6 +260,26 @@ const DELEGABLE_ROUTES: Record = { "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", "app/api/measurement-reminders/[id]/route.ts": "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", + "app/api/labs/route.ts": + "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.", + "app/api/labs/[id]/route.ts": + "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", + "app/api/biomarkers/route.ts": + "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.", + "app/api/biomarkers/[id]/route.ts": + "One biomarker of the record, fetch-then-guard against the resolved user.", + "app/api/allergies/route.ts": + "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.", + "app/api/allergies/[id]/route.ts": + "One allergy of the record, fetch-then-guard against the resolved user.", + "app/api/family-history/route.ts": + "The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.", + "app/api/family-history/[id]/route.ts": + "One family-history entry of the record, fetch-then-guard against the resolved user.", + "app/api/mental-health/assessments/route.ts": + "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", + "app/api/anamnesis/facts/route.ts": + "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", }; /** @@ -283,7 +303,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 7; +const FROZEN_ENTRY_COUNT = 17; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index a2591bf40..621da46b0 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -12,7 +12,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.allergy.findUnique({ where: { id } }); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 385d87dce..8ae18805b 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -29,7 +29,7 @@ import { import { toAllergyDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = new URL(request.url).searchParams; const parsed = allergyListQuerySchema.safeParse({ diff --git a/src/app/api/anamnesis/facts/__tests__/route.test.ts b/src/app/api/anamnesis/facts/__tests__/route.test.ts index a5278b2c4..f9fa02399 100644 --- a/src/app/api/anamnesis/facts/__tests__/route.test.ts +++ b/src/app/api/anamnesis/facts/__tests__/route.test.ts @@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: (handler: unknown) => handler, requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), + // v1.36.0 — the GET resolves a record rather than a caller. Same shape when + // nobody has switched, which is the case this file exercises. + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: (handler: unknown) => handler, diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 08f419d84..228a7d860 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -19,7 +19,7 @@ import { import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const payload = await readHealthProfileFacts(user.id); annotate({ action: { name: "anamnesis.facts.get" }, diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3c0fb11ae..06aa4e5a5 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -64,7 +64,7 @@ function serialiseBiomarker(row: { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.biomarker.findFirst({ where: { id } }); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index f08a10153..256f17ec5 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -71,7 +71,7 @@ function serialiseBiomarker(row: { } export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Visible markers first, then hidden — the manager renders them in two // sections and the lab-entry pickers drop the hidden tail client-side. diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts index 80d02ea8a..47da56ff4 100644 --- a/src/app/api/family-history/[id]/route.ts +++ b/src/app/api/family-history/[id]/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, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.familyHistoryEntry.findUnique({ where: { id } }); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index e005d523e..d58cb44d4 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -30,7 +30,7 @@ import { import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = new URL(request.url).searchParams; const parsed = familyHistoryListQuerySchema.safeParse({ diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index c1a5fc1ef..d83c7de99 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -65,7 +65,7 @@ function toResolved( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.labResult.findFirst({ diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 9c60a96ad..43b099036 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -70,7 +70,7 @@ function toResolved( } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listLabResultsSchema.safeParse(params); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index e7a014910..b2777ab47 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -99,7 +99,7 @@ function respondExisting(row: Parameters[0]): Response { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Opt-in module (default OFF): the screener history is unreachable until the // account turns the mental-health module on. Enforced server-side even for a // valid Bearer token (the nav entry / page redirect are UX-only). diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index c8d437e45..38d4fbce3 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -472,3 +472,371 @@ describe("GET /api/measurement-reminders/[id]", () => { }); }); }); + +/* -------------------------------------------------------------------------- */ +/* Labs and biomarkers */ +/* -------------------------------------------------------------------------- */ + +async function seedLab(userId: string, analyte: string) { + await getPrismaClient().labResult.create({ + data: { + userId, + analyte, + value: 5.2, + unit: "mmol/L", + takenAt: new Date("2026-07-10T09:00:00Z"), + }, + }); +} + +contract("GET /api/labs", { + call: async () => { + const { GET } = await import("@/app/api/labs/route"); + return call(GET as Handler, "/api/labs"); + }, + seed: seedLab, + read: (payload: { results: { analyte: string }[] }) => + payload.results.map((r) => r.analyte), + ownerMarker: "owner-hba1c", + delegateMarker: "delegate-hba1c", +}); + +async function seedBiomarker(userId: string, name: string) { + await getPrismaClient().biomarker.create({ + data: { userId, name, unit: "mmol/L" }, + }); +} + +contract("GET /api/biomarkers", { + call: async () => { + const { GET } = await import("@/app/api/biomarkers/route"); + return call(GET as Handler, "/api/biomarkers"); + }, + seed: seedBiomarker, + read: (payload: { biomarkers: { name: string }[] }) => + payload.biomarkers.map((b) => b.name), + ownerMarker: "owner-ferritin", + delegateMarker: "delegate-ferritin", +}); + +describe("GET /api/labs/[id] and /api/biomarkers/[id]", () => { + it("resolve by id against the owner, not the caller", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedLab(owner.id, "owner-hba1c"); + await seedLab(delegate.id, "delegate-hba1c"); + await seedBiomarker(owner.id, "owner-ferritin"); + + const ownerLab = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateLab = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + const ownerMarker = await getPrismaClient().biomarker.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const labs = await import("@/app/api/labs/[id]/route"); + const markers = await import("@/app/api/biomarkers/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const lab = await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, { + id: ownerLab.id, + }); + expect(lab.status).toBe(200); + expect((await body(lab)).data).toMatchObject({ analyte: "owner-hba1c" }); + + // The delegate's own result is not reachable from inside the owner's + // record. The fetch-then-guard arm now guards against the owner. + const theirs = await call( + labs.GET as Handler, + `/api/labs/${delegateLab.id}`, + { id: delegateLab.id }, + ); + expect(theirs.status).toBe(404); + + const marker = await call( + markers.GET as Handler, + `/api/biomarkers/${ownerMarker.id}`, + { id: ownerMarker.id }, + ); + expect(marker.status).toBe(200); + expect((await body(marker)).data).toMatchObject({ name: "owner-ferritin" }); + + await revokeGrant(grant.id); + await expectDenied( + await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, { + id: ownerLab.id, + }), + ); + await expectDenied( + await call(markers.GET as Handler, `/api/biomarkers/${ownerMarker.id}`, { + id: ownerMarker.id, + }), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedLab(plain.id, "plain-hba1c"); + const row = await getPrismaClient().labResult.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/labs/[id]/route"); + const response = await call(GET as Handler, `/api/labs/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + analyte: "plain-hba1c", + }); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Clinical record */ +/* -------------------------------------------------------------------------- */ + +async function seedAllergy(userId: string, substance: string) { + await getPrismaClient().allergy.create({ data: { userId, substance } }); +} + +contract("GET /api/allergies", { + call: async () => { + const { GET } = await import("@/app/api/allergies/route"); + return call(GET as Handler, "/api/allergies"); + }, + seed: seedAllergy, + read: (payload: { substance: string }[]) => payload.map((a) => a.substance), + ownerMarker: "owner-penicillin", + delegateMarker: "delegate-penicillin", +}); + +async function seedFamilyHistory(userId: string, condition: string) { + await getPrismaClient().familyHistoryEntry.create({ + data: { userId, relationship: "MOTHER", condition }, + }); +} + +contract("GET /api/family-history", { + call: async () => { + const { GET } = await import("@/app/api/family-history/route"); + return call(GET as Handler, "/api/family-history"); + }, + seed: seedFamilyHistory, + read: (payload: { condition: string }[]) => payload.map((e) => e.condition), + ownerMarker: "owner-condition", + delegateMarker: "delegate-condition", +}); + +describe("GET /api/allergies/[id] and /api/family-history/[id]", () => { + it("resolve by id against the owner and refuse once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedAllergy(owner.id, "owner-penicillin"); + await seedAllergy(delegate.id, "delegate-penicillin"); + await seedFamilyHistory(owner.id, "owner-condition"); + + const ownerAllergy = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateAllergy = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + const ownerEntry = + await getPrismaClient().familyHistoryEntry.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const allergies = await import("@/app/api/allergies/[id]/route"); + const history = await import("@/app/api/family-history/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + allergies.GET as Handler, + `/api/allergies/${ownerAllergy.id}`, + { id: ownerAllergy.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ + substance: "owner-penicillin", + }); + + const theirs = await call( + allergies.GET as Handler, + `/api/allergies/${delegateAllergy.id}`, + { id: delegateAllergy.id }, + ); + expect(theirs.status).toBe(404); + + const entry = await call( + history.GET as Handler, + `/api/family-history/${ownerEntry.id}`, + { id: ownerEntry.id }, + ); + expect(entry.status).toBe(200); + expect((await body(entry)).data).toMatchObject({ + condition: "owner-condition", + }); + + await revokeGrant(grant.id); + await expectDenied( + await call( + allergies.GET as Handler, + `/api/allergies/${ownerAllergy.id}`, + { id: ownerAllergy.id }, + ), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedAllergy(plain.id, "plain-penicillin"); + const row = await getPrismaClient().allergy.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/allergies/[id]/route"); + const response = await call(GET as Handler, `/api/allergies/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + substance: "plain-penicillin", + }); + }); +}); + +async function seedFact(userId: string, value: string) { + // Through the real writer, because the revision's value is an encrypted + // column and a hand-built row would read back as `unreadable`. + const { createHealthProfileFact } = + await import("@/lib/profile/health-facts"); + await createHealthProfileFact(userId, "SMOKING_STATUS", value as never); +} + +contract("GET /api/anamnesis/facts", { + call: async () => { + const { GET } = await import("@/app/api/anamnesis/facts/route"); + return call(GET as Handler, "/api/anamnesis/facts"); + }, + seed: seedFact, + read: (payload: { + current: Record; + }) => + Object.values(payload.current) + .map((row) => row?.value ?? null) + .filter((v): v is string => v !== null), + ownerMarker: "CURRENT", + delegateMarker: "NEVER", +}); + +/* -------------------------------------------------------------------------- */ +/* Mental health — the module gate follows the record */ +/* -------------------------------------------------------------------------- */ + +async function seedAssessment(userId: string, totalScore: number) { + await getPrismaClient().mentalHealthAssessment.create({ + data: { + userId, + instrument: "PHQ9", + locale: "en", + responsesEncrypted: Buffer.from("sealed"), + totalScore, + severityBand: "mild", + takenAt: new Date("2026-07-12T10:00:00Z"), + }, + }); +} + +async function setModulePreference(userId: string, enabled: boolean) { + await getPrismaClient().user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { mentalHealth: enabled } }, + }); +} + +describe("GET /api/mental-health/assessments", () => { + it("reads the owner's screener history when the OWNER enabled the module", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, true); + await seedAssessment(owner.id, 11); + await seedAssessment(delegate.id, 3); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + assessments: { totalScore: number }[]; + }; + expect(payload.assessments.map((a) => a.totalScore)).toEqual([11]); + }); + + it("refuses when the OWNER switched the module off, whatever the delegate set", async () => { + // The decision recorded in the classification: the module map governs + // which parts of a RECORD exist, so it is read off the record. A delegate + // who enabled the screener on their own account does not thereby gain a + // screener view inside somebody else's. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, false); + await setModulePreference(delegate.id, true); + await seedAssessment(owner.id, 11); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + + expect(response.status).toBe(403); + expect((await body(response)).meta?.errorCode).toBe("module.disabled"); + }); + + it("refuses a caller with no grant, and refuses the request after revocation", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await setModulePreference(owner.id, true); + await seedAssessment(owner.id, 11); + + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const { grant } = await switchInto(owner.id, delegate.id); + expect( + (await call(GET as Handler, "/api/mental-health/assessments")).status, + ).toBe(200); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, "/api/mental-health/assessments"), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await setModulePreference(plain.id, true); + await seedAssessment(plain.id, 7); + await signIn(plain.id); + + const { GET } = await import("@/app/api/mental-health/assessments/route"); + const response = await call( + GET as Handler, + "/api/mental-health/assessments", + ); + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + assessments: { totalScore: number }[]; + }; + expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]); + }); +}); From 8c070c953b550aab53469b1d117090cd8846def4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:44:52 +0200 Subject: [PATCH 3/8] feat(sharing): open the mood, custom-metric and record reads to a delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six routes: the mood-entry list and detail, the custom-metric list, detail and entries, and the personal-record list. The custom-metric entries read establishes ownership on the metric before it queries by metric id, and that hop now resolves against the record — a delegate cannot reach their own metric from inside somebody else's account. `GET /api/mood/tags` stays refused. It looks like plain record vocabulary and one select in the middle of it pulls the owner's tag layout, which is a presentation preference and belongs to the person rather than the record. The mood list's validation breadcrumb moves onto `auditLog()` for the actor stamp, the same change the measurement reads took. --- src/__tests__/delegable-surface-guard.test.ts | 14 +- .../api/custom-metrics/[id]/entries/route.ts | 4 +- src/app/api/custom-metrics/[id]/route.ts | 4 +- src/app/api/custom-metrics/route.ts | 4 +- src/app/api/mood-entries/[id]/route.ts | 4 +- .../api/mood-entries/__tests__/route.test.ts | 23 +- src/app/api/mood-entries/route.ts | 25 +-- src/app/api/personal-records/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 211 ++++++++++++++++++ 9 files changed, 258 insertions(+), 35 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index f3ad80b47..18ed4790e 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -280,6 +280,18 @@ const DELEGABLE_ROUTES: Record = { "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", "app/api/anamnesis/facts/route.ts": "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", + "app/api/mood-entries/route.ts": + "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", + "app/api/mood-entries/[id]/route.ts": + "One mood entry of the record, fetch-then-guard against the resolved user.", + "app/api/custom-metrics/route.ts": + "The record's own metric definitions and their latest values. Every `where` carries the resolved user id.", + "app/api/custom-metrics/[id]/route.ts": + "One custom metric of the record, scoped by the resolved user id in the query itself.", + "app/api/custom-metrics/[id]/entries/route.ts": + "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.", + "app/api/personal-records/route.ts": + "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", }; /** @@ -303,7 +315,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 17; +const FROZEN_ENTRY_COUNT = 23; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 5c96553b2..b6963086c 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index b50b2726c..7925b7d8b 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -38,7 +38,7 @@ function effectiveBound( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const row = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 06b8e7d5e..0d4a71e29 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics"; */ export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const rows = await prisma.customMetric.findMany({ where: { userId: user.id, deletedAt: null }, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index ce9843d91..a9166ad71 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -11,7 +11,7 @@ import { import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, 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"; @@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] { export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); 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 1b54d3e0c..71717a25f 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -51,9 +51,7 @@ vi.mock("@/lib/mood/tag-links", async () => { }); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: (fn: (...args: Args) => Promise) => @@ -80,6 +78,7 @@ vi.mock("next/headers", () => ({ })); import { GET, POST } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { createTagLinks } from "@/lib/mood/tag-links"; @@ -107,6 +106,11 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper now, because that helper is the only writer that stamps + // `actorUserId`. `resetAllMocks` clears the factory's implementation, so the + // resolved promise the route attaches a `.catch` to is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); // Re-establish the interactive-transaction passthrough after the reset. vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient), @@ -152,17 +156,14 @@ describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => { const res = await GET(getReq("mood=junk&sortBy=garbage")); 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.list.validation-failed"); + expect(auditLog).toHaveBeenCalledWith( + "mood-entries.list.validation-failed", + expect.objectContaining({ userId: "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 GET(getReq("mood=junk&sortBy=garbage")); expect(res.status).toBe(422); }); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 2a98b2ba6..699baac0b 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; @@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const params = Object.fromEntries(request.nextUrl.searchParams); const parsed = listMoodEntriesSchema.safeParse(params); @@ -61,17 +61,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "mood-entries.list.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("mood-entries.list.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422, { errorCode: "mood.list.invalid", }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 65a971a0e..54847fce1 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -16,7 +16,7 @@ */ import { prisma } from "@/lib/db"; import type { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100; const MAX_LIMIT = 500; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); annotate({ action: { name: "personalRecords.list" } }); const { searchParams } = new URL(request.url); diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 38d4fbce3..86177c543 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -840,3 +840,214 @@ describe("GET /api/mental-health/assessments", () => { expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]); }); }); + +/* -------------------------------------------------------------------------- */ +/* Mood, custom metrics, personal records */ +/* -------------------------------------------------------------------------- */ + +async function seedMood(userId: string, score: number) { + await getPrismaClient().moodEntry.create({ + data: { + userId, + date: "2026-07-14", + mood: "GUT", + score, + moodLoggedAt: new Date("2026-07-14T19:00:00Z"), + }, + }); +} + +contract("GET /api/mood-entries", { + call: async () => { + const { GET } = await import("@/app/api/mood-entries/route"); + return call(GET as Handler, "/api/mood-entries"); + }, + seed: seedMood, + read: (payload: { entries: { score: number }[] }) => + payload.entries.map((e) => e.score), + ownerMarker: 4, + delegateMarker: 2, +}); + +describe("GET /api/mood-entries/[id]", () => { + it("resolves against the owner and refuses once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMood(owner.id, 4); + await seedMood(delegate.id, 2); + const ownerRow = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateRow = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + GET as Handler, + `/api/mood-entries/${ownerRow.id}`, + { id: ownerRow.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ score: 4 }); + + const theirs = await call( + GET as Handler, + `/api/mood-entries/${delegateRow.id}`, + { id: delegateRow.id }, + ); + expect(theirs.status).toBe(404); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, `/api/mood-entries/${ownerRow.id}`, { + id: ownerRow.id, + }), + ); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedMood(plain.id, 5); + const row = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/mood-entries/[id]/route"); + const response = await call(GET as Handler, `/api/mood-entries/${row.id}`, { + id: row.id, + }); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ score: 5 }); + }); +}); + +async function seedCustomMetric(userId: string, name: string) { + const metric = await getPrismaClient().customMetric.create({ + data: { userId, name, unit: "ml" }, + }); + await getPrismaClient().customMetricEntry.create({ + data: { + userId, + customMetricId: metric.id, + value: 250, + unit: "ml", + measuredAt: new Date("2026-07-14T12:00:00Z"), + }, + }); +} + +contract("GET /api/custom-metrics", { + call: async () => { + const { GET } = await import("@/app/api/custom-metrics/route"); + return call(GET as Handler, "/api/custom-metrics"); + }, + seed: seedCustomMetric, + read: (payload: { customMetrics: { name: string }[] }) => + payload.customMetrics.map((m) => m.name), + ownerMarker: "owner-water", + delegateMarker: "delegate-water", +}); + +describe("GET /api/custom-metrics/[id] and its entries", () => { + it("reach the owner's metric and refuse once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCustomMetric(owner.id, "owner-water"); + await seedCustomMetric(delegate.id, "delegate-water"); + const ownerMetric = await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateMetric = + await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const detail = await import("@/app/api/custom-metrics/[id]/route"); + const entries = await import("@/app/api/custom-metrics/[id]/entries/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + detail.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}`, + { id: ownerMetric.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ name: "owner-water" }); + + // The entries route establishes ownership on the METRIC first and only + // then queries by `customMetricId`. That hop now resolves against the + // owner, so the delegate's own metric is unreachable from in here. + const theirEntries = await call( + entries.GET as Handler, + `/api/custom-metrics/${delegateMetric.id}/entries`, + { id: delegateMetric.id }, + ); + expect(theirEntries.status).toBe(404); + + const ownerEntries = await call( + entries.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}/entries`, + { id: ownerMetric.id }, + ); + expect(ownerEntries.status).toBe(200); + const payload = (await body(ownerEntries)).data as { + entries: { value: number }[]; + }; + expect(payload.entries.map((e) => e.value)).toEqual([250]); + + await revokeGrant(grant.id); + await expectDenied( + await call( + entries.GET as Handler, + `/api/custom-metrics/${ownerMetric.id}/entries`, + { id: ownerMetric.id }, + ), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedCustomMetric(plain.id, "plain-water"); + const metric = await getPrismaClient().customMetric.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/custom-metrics/[id]/route"); + const response = await call( + GET as Handler, + `/api/custom-metrics/${metric.id}`, + { id: metric.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ name: "plain-water" }); + }); +}); + +async function seedPersonalRecord(userId: string, value: number) { + await getPrismaClient().personalRecord.create({ + data: { + userId, + metricType: "WEIGHT", + direction: "MIN", + value, + unit: "kg", + achievedAt: new Date("2026-06-01T08:00:00Z"), + }, + }); +} + +contract("GET /api/personal-records", { + call: async () => { + const { GET } = await import("@/app/api/personal-records/route"); + return call(GET as Handler, "/api/personal-records"); + }, + seed: seedPersonalRecord, + read: (payload: { value: number }[]) => payload.map((r) => r.value), + ownerMarker: 79.5, + delegateMarker: 61.5, +}); From 10f9a83356245c80b7d27f46af7690231e4af3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:52:59 +0200 Subject: [PATCH 4/8] feat(sharing): open the sleep, nutrient, illness and workout reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight routes: the two sleep reads, the nutrient overview and its daily series, the illness episode list, detail and day logs, and the workout list. The illness day-log read is the one worth reading twice. Its queries are episode-scoped rather than user-scoped, and what makes them safe is the `loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against the record, so a delegate cannot reach their own episode from inside somebody else's account — asserted, because it is the kind of thing that would look fine and behave wrong. `GET /api/workouts/[id]` stays refused. It differs from the list beside it by one conditional include that decrypts a paragraph written about the owner. --- src/__tests__/delegable-surface-guard.test.ts | 18 +- .../illness/episodes/[id]/day-logs/route.ts | 4 +- src/app/api/illness/episodes/[id]/route.ts | 4 +- src/app/api/illness/episodes/route.ts | 4 +- src/app/api/nutrients/daily/route.ts | 4 +- src/app/api/nutrients/route.ts | 4 +- src/app/api/sleep/night/route.ts | 4 +- src/app/api/sleep/rhythm/route.ts | 4 +- src/app/api/workouts/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 264 ++++++++++++++++++ 10 files changed, 297 insertions(+), 17 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 18ed4790e..b23187580 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -292,6 +292,22 @@ const DELEGABLE_ROUTES: Record = { "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query.", "app/api/personal-records/route.ts": "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", + "app/api/sleep/night/route.ts": + "The record's hypnogram. Read-only, module-gated on the record, no write of any kind on the path.", + "app/api/sleep/rhythm/route.ts": + "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.", + "app/api/nutrients/route.ts": + "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.", + "app/api/nutrients/daily/route.ts": + "One nutrient's daily series over the record, scoped by the resolved user id.", + "app/api/illness/episodes/route.ts": + "The record's illness episodes. Module-gated on the record; the create arm keeps `requireAuth()`.", + "app/api/illness/episodes/[id]/route.ts": + "One episode of the record, fetch-then-guard against the resolved user.", + "app/api/illness/episodes/[id]/day-logs/route.ts": + "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", + "app/api/workouts/route.ts": + "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", }; /** @@ -315,7 +331,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 23; +const FROZEN_ENTRY_COUNT = 31; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — 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 7dc3660e5..1f013ecb5 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -55,7 +55,7 @@ async function loadOwnedEpisode( export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); 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 abeaa82e5..9bc0df39e 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/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, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -30,7 +30,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 154e0c880..a4a0d0d11 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/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, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -33,7 +33,7 @@ import { import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; diff --git a/src/app/api/nutrients/daily/route.ts b/src/app/api/nutrients/daily/route.ts index 382200b4b..5e1b561ce 100644 --- a/src/app/api/nutrients/daily/route.ts +++ b/src/app/api/nutrients/daily/route.ts @@ -19,7 +19,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 { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { binaryReferenceSex } from "@/lib/profile/sex"; @@ -34,7 +34,7 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireModuleEnabled(user.id, "nutrients"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 1e22c5a27..0bc423720 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -17,7 +17,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 { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -92,7 +92,7 @@ function pickTopSkipReason(byReason: Record): string | null { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireModuleEnabled(user.id, "nutrients"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 9f6267382..283fa25f5 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -24,7 +24,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 { apiSuccess, returnAllZodIssues, @@ -105,7 +105,7 @@ function serializeSession(s: SleepSession) { } export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Per-domain gate: the hypnogram read serves only the sleep module's // view surfaces, so it gates on the sleep module. Disabled ⇒ 403. diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts index 898f8463b..007a154b0 100644 --- a/src/app/api/sleep/rhythm/route.ts +++ b/src/app/api/sleep/rhythm/route.ts @@ -19,14 +19,14 @@ * * Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered. */ -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 { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm"; import { requireModuleEnabled } from "@/lib/modules/gate"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Per-domain gate: the sleep-rhythm read serves only the sleep module's // view surfaces (Sleep page + iOS), so it gates on the sleep module. diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 526606caf..ca5bd6623 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -57,7 +57,7 @@ */ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { @@ -70,7 +70,7 @@ const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); annotate({ action: { name: "workouts.list" } }); // v1.18.0 B1 — the workouts surface is a toggleable module. A disabled diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 86177c543..4c2af371e 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1051,3 +1051,267 @@ contract("GET /api/personal-records", { ownerMarker: 79.5, delegateMarker: 61.5, }); + +/* -------------------------------------------------------------------------- */ +/* Sleep, nutrients, illness, workouts */ +/* -------------------------------------------------------------------------- */ + +async function seedSleepStage(userId: string, minutes: number) { + await getPrismaClient().measurement.create({ + data: { + userId, + type: "SLEEP_DURATION", + value: minutes, + unit: "min", + measuredAt: new Date(Date.now() - 8 * 3_600_000), + source: "MANUAL", + sleepStage: "DEEP", + }, + }); +} + +describe("GET /api/sleep/night", () => { + it("reads the owner's night and refuses once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedSleepStage(owner.id, 90); + await seedSleepStage(delegate.id, 30); + + const { GET } = await import("@/app/api/sleep/night/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const response = await call(GET as Handler, "/api/sleep/night"); + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + main: { stages?: unknown; totalMinutes?: number } | null; + }; + // The owner's 90 minutes, not the delegate's 30. + expect(JSON.stringify(payload)).toContain("90"); + expect(JSON.stringify(payload)).not.toContain('"30"'); + + await revokeGrant(grant.id); + await expectDenied(await call(GET as Handler, "/api/sleep/night")); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedSleepStage(plain.id, 90); + await signIn(plain.id); + + const { GET } = await import("@/app/api/sleep/night/route"); + const response = await call(GET as Handler, "/api/sleep/night"); + expect(response.status).toBe(200); + expect(JSON.stringify((await body(response)).data)).toContain("90"); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + const { GET } = await import("@/app/api/sleep/night/route"); + await expectDenied(await call(GET as Handler, "/api/sleep/night")); + }); +}); + +describe("GET /api/sleep/rhythm", () => { + it("follows the record's sleep module, not the delegate's", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await getPrismaClient().user.update({ + where: { id: owner.id }, + data: { modulePreferencesJson: { sleep: false } }, + }); + await getPrismaClient().user.update({ + where: { id: delegate.id }, + data: { modulePreferencesJson: { sleep: true } }, + }); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/sleep/rhythm/route"); + const response = await call(GET as Handler, "/api/sleep/rhythm"); + + expect(response.status).toBe(403); + expect((await body(response)).meta?.errorCode).toBe("module.disabled"); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await signIn(plain.id); + const { GET } = await import("@/app/api/sleep/rhythm/route"); + expect((await call(GET as Handler, "/api/sleep/rhythm")).status).toBe(200); + }); +}); + +async function seedNutrient(userId: string, amount: number) { + // Opt-in module, default off. Enabled on whoever is seeded, so the delegate + // case below exercises the substitution rather than the gate. + await getPrismaClient().user.update({ + where: { id: userId }, + data: { modulePreferencesJson: { nutrients: true } }, + }); + const day = new Date().toISOString().slice(0, 10); + await getPrismaClient().nutrientIntakeDay.create({ + data: { userId, day, nutrient: "magnesium", amount, unit: "mg" }, + }); +} + +contract("GET /api/nutrients", { + call: async () => { + const { GET } = await import("@/app/api/nutrients/route"); + return call(GET as Handler, "/api/nutrients?days=30"); + }, + seed: seedNutrient, + read: (payload: { nutrients: { latestAmount: number }[] }) => + payload.nutrients.map((n) => n.latestAmount), + ownerMarker: 320, + delegateMarker: 145, +}); + +contract("GET /api/nutrients/daily", { + call: async () => { + const { GET } = await import("@/app/api/nutrients/daily/route"); + return call(GET as Handler, "/api/nutrients/daily?nutrient=magnesium"); + }, + seed: seedNutrient, + read: (payload: { days: { amount: number }[] }) => + payload.days.map((d) => d.amount).filter((a) => a > 0), + ownerMarker: 320, + delegateMarker: 145, +}); + +async function seedEpisode(userId: string, label: string) { + const episode = await getPrismaClient().illnessEpisode.create({ + data: { + userId, + label, + type: "INFECTION", + onsetAt: new Date("2026-07-01T00:00:00Z"), + }, + }); + await getPrismaClient().illnessDayLog.create({ + data: { + userId, + episodeId: episode.id, + date: "2026-07-02", + functionalImpact: 3, + }, + }); +} + +contract("GET /api/illness/episodes", { + call: async () => { + const { GET } = await import("@/app/api/illness/episodes/route"); + return call(GET as Handler, "/api/illness/episodes"); + }, + seed: seedEpisode, + read: (payload: { label: string }[]) => payload.map((e) => e.label), + ownerMarker: "owner-flu", + delegateMarker: "delegate-flu", +}); + +describe("GET /api/illness/episodes/[id] and its day logs", () => { + it("resolve the ownership hop against the record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedEpisode(owner.id, "owner-flu"); + await seedEpisode(delegate.id, "delegate-flu"); + const ownerEpisode = + await getPrismaClient().illnessEpisode.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateEpisode = + await getPrismaClient().illnessEpisode.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const detail = await import("@/app/api/illness/episodes/[id]/route"); + const logs = await import("@/app/api/illness/episodes/[id]/day-logs/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + detail.GET as Handler, + `/api/illness/episodes/${ownerEpisode.id}`, + { id: ownerEpisode.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ label: "owner-flu" }); + + // `loadOwnedEpisode` is what makes the episode-scoped day-log query safe. + // It now resolves against the owner, so the delegate's own episode 404s + // from in here rather than leaking its logs. + const theirs = await call( + logs.GET as Handler, + `/api/illness/episodes/${delegateEpisode.id}/day-logs?date=2026-07-02`, + { id: delegateEpisode.id }, + ); + expect(theirs.status).toBe(404); + + const ownerLog = await call( + logs.GET as Handler, + `/api/illness/episodes/${ownerEpisode.id}/day-logs?date=2026-07-02`, + { id: ownerEpisode.id }, + ); + expect(ownerLog.status).toBe(200); + expect((await body(ownerLog)).data).toMatchObject({ + functionalImpact: 3, + }); + + await revokeGrant(grant.id); + await expectDenied( + await call( + detail.GET as Handler, + `/api/illness/episodes/${ownerEpisode.id}`, + { id: ownerEpisode.id }, + ), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedEpisode(plain.id, "plain-flu"); + const episode = await getPrismaClient().illnessEpisode.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/illness/episodes/[id]/route"); + const response = await call( + GET as Handler, + `/api/illness/episodes/${episode.id}`, + { id: episode.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ label: "plain-flu" }); + }); +}); + +async function seedWorkout(userId: string, durationSec: number) { + await getPrismaClient().workout.create({ + data: { + userId, + sportType: "RUNNING", + startedAt: new Date("2026-07-11T06:00:00Z"), + endedAt: new Date("2026-07-11T07:00:00Z"), + durationSec, + }, + }); +} + +contract("GET /api/workouts", { + call: async () => { + const { GET } = await import("@/app/api/workouts/route"); + return call(GET as Handler, "/api/workouts"); + }, + seed: seedWorkout, + read: (payload: { workouts: { durationSec: number | null }[] }) => + payload.workouts + .map((w) => w.durationSec) + .filter((d): d is number => d !== null), + ownerMarker: 3600, + delegateMarker: 1800, +}); From fe765a7ae2561895f767662e87e45b96f0a483e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 16:57:07 +0200 Subject: [PATCH 5/8] feat(sharing): open the cycle reads to a delegate Four routes: the cycle history, the day log, the profile and the custom symptom vocabulary. All four gate on `requireCycleEnabled(user.id, user.gender)`, and both arguments now come off the record. That is the correct reading and the one that had to be verified rather than assumed: whether cycle tracking applies is a property of the record, so a delegate who tracks nothing still sees the owner's cycle, and a delegate who tracks their own sees nothing when the owner does not. Both directions are asserted. --- src/__tests__/delegable-surface-guard.test.ts | 10 +- src/app/api/cycle/cycles/route.ts | 4 +- src/app/api/cycle/day-logs/route.ts | 4 +- src/app/api/cycle/profile/route.ts | 4 +- src/app/api/cycle/symptoms/custom/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 148 ++++++++++++++++++ 6 files changed, 165 insertions(+), 9 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index b23187580..543024fc6 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -308,6 +308,14 @@ const DELEGABLE_ROUTES: Record = { "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", "app/api/workouts/route.ts": "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", + "app/api/cycle/cycles/route.ts": + "The record's cycle history. The gate reads `user.gender` off the resolved record, which is the point: whether cycle tracking applies is a property of the record, not of whoever is looking at it.", + "app/api/cycle/day-logs/route.ts": + "One day of the record's cycle log. The `auditLog(\"cycle.day-log.upsert\")` in this file belongs to the PUT, which keeps `requireAuth()`.", + "app/api/cycle/profile/route.ts": + "The record's cycle profile, supplied by the gate itself. No query of its own.", + "app/api/cycle/symptoms/custom/route.ts": + "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", }; /** @@ -331,7 +339,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 31; +const FROZEN_ENTRY_COUNT = 35; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index ae66aded0..e5619c70a 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/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 { apiError, apiSuccess } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24; const REGULAR_MAD_THRESHOLD = 2; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); 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 e8ad461da..421c6b447 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -14,7 +14,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -48,7 +48,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); * row id. Gated + owner-scoped + `deletedAt:null`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts index 051d916d0..8b6fbb04b 100644 --- a/src/app/api/cycle/profile/route.ts +++ b/src/app/api/cycle/profile/route.ts @@ -4,7 +4,7 @@ * Gated like every cycle route; returns the lazily-upserted profile with * the resolved `cycleTrackingEnabled`. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index b250aeb1f..b8b2e22d6 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 } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -41,7 +41,7 @@ import { export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 4c2af371e..4704225f6 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1315,3 +1315,151 @@ contract("GET /api/workouts", { ownerMarker: 3600, delegateMarker: 1800, }); + +/* -------------------------------------------------------------------------- */ +/* Cycle — the gate reads the record's own gender */ +/* -------------------------------------------------------------------------- */ + +/** + * A cycle-tracking account. `cycleTrackingEnabled` is an explicit opt-in that + * overrides the gender derivation, so the fixture does not depend on the enum + * normalisation the gate's own comment flags as still pending. + */ +async function seedCycle(userId: string, startDate: string) { + await getPrismaClient().user.update({ + where: { id: userId }, + data: { gender: "FEMALE" }, + }); + await getPrismaClient().cycleProfile.upsert({ + where: { userId }, + create: { userId, cycleTrackingEnabled: true }, + update: { cycleTrackingEnabled: true }, + }); + await getPrismaClient().menstrualCycle.create({ + data: { userId, startDate }, + }); +} + +contract("GET /api/cycle/cycles", { + call: async () => { + const { GET } = await import("@/app/api/cycle/cycles/route"); + return call(GET as Handler, "/api/cycle/cycles"); + }, + seed: seedCycle, + read: (payload: { cycles: { startDate: string }[] }) => + payload.cycles.map((c) => c.startDate), + ownerMarker: "2026-07-01", + delegateMarker: "2026-06-02", +}); + +describe("GET /api/cycle/profile", () => { + it("resolves the gate against the record, not the caller", async () => { + // The delegate does not track a cycle. The owner does. A gate reading the + // caller's own gender and profile would refuse this request — the module + // decision belongs to the record. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCycle(owner.id, "2026-07-01"); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/cycle/profile/route"); + const response = await call(GET as Handler, "/api/cycle/profile"); + + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + }); + + it("refuses when the RECORD does not track a cycle, whatever the caller does", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCycle(delegate.id, "2026-06-02"); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/cycle/profile/route"); + const response = await call(GET as Handler, "/api/cycle/profile"); + + expect(response.status).toBe(403); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCycle(owner.id, "2026-07-01"); + + const { GET } = await import("@/app/api/cycle/profile/route"); + const { grant } = await switchInto(owner.id, delegate.id); + expect((await call(GET as Handler, "/api/cycle/profile")).status).toBe(200); + + await revokeGrant(grant.id); + await expectDenied(await call(GET as Handler, "/api/cycle/profile")); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedCycle(plain.id, "2026-07-01"); + await signIn(plain.id); + + const { GET } = await import("@/app/api/cycle/profile/route"); + const response = await call(GET as Handler, "/api/cycle/profile"); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + }); +}); + +describe("GET /api/cycle/day-logs and /api/cycle/symptoms/custom", () => { + it("read the owner's rows and refuse once the grant ends", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedCycle(owner.id, "2026-07-01"); + await seedCycle(delegate.id, "2026-06-02"); + await getPrismaClient().cycleDayLog.create({ + data: { userId: owner.id, date: "2026-07-03", basalBodyTempC: 36.7 }, + }); + await getPrismaClient().cycleDayLog.create({ + data: { userId: delegate.id, date: "2026-07-03", basalBodyTempC: 36.1 }, + }); + + const logs = await import("@/app/api/cycle/day-logs/route"); + const symptoms = await import("@/app/api/cycle/symptoms/custom/route"); + const { grant } = await switchInto(owner.id, delegate.id); + + const log = await call( + logs.GET as Handler, + "/api/cycle/day-logs?date=2026-07-03", + ); + expect(log.status).toBe(200); + expect((await body(log)).data).toMatchObject({ basalBodyTempC: 36.7 }); + + expect( + (await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom")) + .status, + ).toBe(200); + + await revokeGrant(grant.id); + await expectDenied( + await call(logs.GET as Handler, "/api/cycle/day-logs?date=2026-07-03"), + ); + await expectDenied( + await call(symptoms.GET as Handler, "/api/cycle/symptoms/custom"), + ); + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedCycle(plain.id, "2026-07-01"); + await getPrismaClient().cycleDayLog.create({ + data: { userId: plain.id, date: "2026-07-03", basalBodyTempC: 36.4 }, + }); + await signIn(plain.id); + + const { GET } = await import("@/app/api/cycle/day-logs/route"); + const response = await call( + GET as Handler, + "/api/cycle/day-logs?date=2026-07-03", + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + basalBodyTempC: 36.4, + }); + }); +}); From 7a39a649be2cd0d4e9b6b1a1033ffb26377a0c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 17:06:23 +0200 Subject: [PATCH 6/8] feat(sharing): open the medication reads to a delegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten routes: the cabinet list, the medication detail, its cadence, schedule revisions, side effects, inventory, phase config and intake history, plus the account-wide intake list and the batched compliance read. `GET /api/medications/compliance` is the one delegable read that spends a quota, and its rate-limit bucket now keys on the ACTOR while everything below it scopes to the record. Both halves matter: a delegate hammering the route must burn their own allowance rather than lock the owner out of their own cabinet, and switching records must not hand the same caller a fresh one. The test reads the `rate_limits` row rather than inferring it from a 429, because a bucket keyed on the wrong person still answers 200. `GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar fields and no secret, and it tells a delegate whether the owner holds a live ingest credential and for which medication — in the same file as the PUT that mints one that would survive revocation. The two intake validation breadcrumbs move onto `auditLog()` for the actor stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached under a switch. --- src/__tests__/delegable-surface-guard.test.ts | 57 +++- src/app/api/medications/[id]/cadence/route.ts | 4 +- .../[id]/intake/__tests__/route.test.ts | 17 +- src/app/api/medications/[id]/intake/route.ts | 28 +- .../api/medications/[id]/inventory/route.ts | 4 +- .../medications/[id]/phase-config/route.ts | 4 +- src/app/api/medications/[id]/route.ts | 4 +- .../[id]/schedule-revisions/route.ts | 4 +- .../medications/[id]/side-effects/route.ts | 4 +- src/app/api/medications/compliance/route.ts | 11 +- .../intake/__tests__/route.test.ts | 9 +- src/app/api/medications/intake/route.ts | 25 +- src/app/api/medications/route.ts | 4 +- .../sharing-delegable-routes.test.ts | 297 +++++++++++++++++- 14 files changed, 401 insertions(+), 71 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 543024fc6..748e4b8bc 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -24,12 +24,15 @@ * 1. **Every leg asserts its own matcher found something.** A matcher that * matches nothing reports success, and this repository has shipped several * of those — a resolver hidden behind a newline, a regex a variable slipped - * past, an absence asserted in a fixture that never held the thing. The - * frozen lists below start EMPTY, so "found nothing" and "the list is - * correct" look identical from the outside. Each leg therefore pins a - * separate, non-empty count: the number of route modules SCANNED, or the - * number of files naming a symbol the leg depends on. Those counts are the - * evidence; the empty result is only the verdict. + * past, an absence asserted in a fixture that never held the thing. Legs + * (c) and (d) still expect an EMPTY offender set, so for them "found + * nothing" and "the list is correct" look identical from the outside. + * Each leg therefore pins a separate, non-empty count: the number of + * route modules SCANNED, or the number of files naming a symbol the leg + * depends on. Those counts are the evidence; the empty result is only the + * verdict. The delegable list itself was empty when this file was written + * and is not any more, which removes the trap from legs (a) and (b) but + * not from the two below them. * * 2. **AST decides, text only skips.** Membership is decided by parsing the * module and reading its identifiers, so a comment explaining why a route @@ -311,11 +314,31 @@ const DELEGABLE_ROUTES: Record = { "app/api/cycle/cycles/route.ts": "The record's cycle history. The gate reads `user.gender` off the resolved record, which is the point: whether cycle tracking applies is a property of the record, not of whoever is looking at it.", "app/api/cycle/day-logs/route.ts": - "One day of the record's cycle log. The `auditLog(\"cycle.day-log.upsert\")` in this file belongs to the PUT, which keeps `requireAuth()`.", + 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', "app/api/cycle/profile/route.ts": "The record's cycle profile, supplied by the gate itself. No query of its own.", "app/api/cycle/symptoms/custom/route.ts": "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", + "app/api/medications/route.ts": + "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", + "app/api/medications/[id]/route.ts": + "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", + "app/api/medications/[id]/cadence/route.ts": + "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.", + "app/api/medications/[id]/schedule-revisions/route.ts": + "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", + "app/api/medications/[id]/side-effects/route.ts": + "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + "app/api/medications/[id]/inventory/route.ts": + "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", + "app/api/medications/[id]/phase-config/route.ts": + "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", + "app/api/medications/[id]/intake/route.ts": + "One medication's intake history, behind the same ownership guard. The taking of a dose is a write and stays on `requireAuth()`.", + "app/api/medications/intake/route.ts": + "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to.", + "app/api/medications/compliance/route.ts": + "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", }; /** @@ -339,7 +362,7 @@ const ACTOR_ROUTES: Record = { * 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. */ -const FROZEN_ENTRY_COUNT = 35; +const FROZEN_ENTRY_COUNT = 45; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -453,8 +476,8 @@ describe("the matchers see what they look for", () => { }); it("the co-occurrence rule fires on a module that does both", () => { - // Leg (c) finds nothing on today's tree because nothing calls the record - // resolver yet. This is the shape it exists to catch, proved against the + // Leg (c) finds nothing on today's tree because no delegable module runs a + // role check. This is the shape it exists to catch, proved against the // same predicate the tree scan uses. const OFFENDER = ` import { requireRecordAuth, requireAdmin } from "@/lib/api-handler"; @@ -530,11 +553,13 @@ describe("(a) the delegable route set is frozen", () => { "app/.well-known/oauth-protected-resource/route.ts", ); - // Positive control for this leg's own matcher, and the reason it exists: - // while the frozen list is empty, "no route names the resolver" and "the - // matcher names nothing" are the same sentence. Neuter `identifiersIn` and - // the assertion below still passes; this one does not. `lib/api-handler.ts` - // is the guaranteed positive — it declares the symbol. + // Positive control for this leg's own matcher. It was written when the + // frozen list was empty and "no route names the resolver" and "the matcher + // names nothing" were the same sentence; the list is populated now, so the + // assertion below would catch a neutered matcher on its own. Kept anyway, + // because a future removal could empty the list again and the day that + // happens is not the day to rediscover this. `lib/api-handler.ts` is the + // guaranteed positive — it declares the symbol. expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([ "lib/api-handler.ts", ]); @@ -594,7 +619,7 @@ describe("(c) a record resolver never shares a module with a role or step-up che // This leg's own non-zero proof, and the one that matters most here: the // offender set is empty today whether or not the helper names are right, - // because nothing calls the record resolver yet. So each helper has to be + // because no delegable module runs a role check. So each helper has to be // findable in the very tree being scanned. Rename `requireAdmin` and this // fails, rather than the guard quietly checking for a symbol that no // longer exists. diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 13c3967ff..dae2a9dd4 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -18,7 +18,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; 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 { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { @@ -42,7 +42,7 @@ const querySchema = z.object({ export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 870ca4e7d..b76584056 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -72,9 +72,7 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); @@ -100,6 +98,7 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ import { GET, POST } from "../route"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -127,6 +126,10 @@ const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; beforeEach(() => { vi.resetAllMocks(); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper, whose promise the route attaches a `.catch` to. `resetAllMocks` + // clears the factory's implementation, so it is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( @@ -292,13 +295,19 @@ describe("v1.4.43 W6 — multi-issue 422 envelope", () => { await GET(makeRequest("status=junk"), ROUTE_PARAMS); await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS); await new Promise((r) => setTimeout(r, 5)); + // v1.36.0 — the GET's breadcrumb goes through the `auditLog` helper (the + // only writer that stamps `actorUserId`); the POST's still writes the row + // directly, because that arm cannot be reached under a switch. + expect(auditLog).toHaveBeenCalledWith( + "medications.intake.list.validation-failed", + expect.objectContaining({ userId: "user-1" }), + ); expect(prisma.auditLog.create).toHaveBeenCalled(); const actions = vi .mocked(prisma.auditLog.create) .mock.calls.map( (c) => (c[0] as { data: { action: string } }).data.action, ); - expect(actions).toContain("medications.intake.list.validation-failed"); expect(actions).toContain("medications.intake.create.validation-failed"); }); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 99d027529..26ac314e5 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -538,7 +538,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. @@ -559,20 +559,16 @@ export const GET = apiHandler( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.list.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - medicationId: id, - }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a delegate's malformed query would read as the owner's own. + void auditLog("medications.intake.list.validation-failed", { + userId: user.id, + details: { issues: auditIssues, medicationId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index d22a8572b..ea361cabf 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; 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 { @@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index b8eada6c1..ad5883877 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; @@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> }; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 8bd2587af..2a9c5b9a6 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -76,7 +76,7 @@ function reconcileScheduleWindow( export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const medication = await prisma.medication.findUnique({ diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index ee5a1ec3c..b91699a4d 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -23,7 +23,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{ export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 5562e4873..db2fafd08 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -19,7 +19,7 @@ import { NextRequest } from "next/server"; 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 { @@ -45,7 +45,7 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const { id } = await params; const guard = await assertMedicationOwnership(id, user.id); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 6c6263e02..f120a7a35 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,7 +1,7 @@ import pLimit from "p-limit"; 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 { apiSuccess, apiError } from "@/lib/api-response"; import { @@ -26,10 +26,15 @@ import { checkRateLimit } from "@/lib/rate-limit"; * detail page keeps the per-id route for the grid. */ export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user, actor } = await requireRecordAuth("read"); + // v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to + // the record. Two reasons, and both are the same reason from opposite ends: + // a delegate hammering this route must burn their own allowance rather than + // locking the owner out of their own cabinet, and switching records must not + // hand the same caller a fresh allowance. `user.id` would get both wrong. const rl = await checkRateLimit( - `medication-compliance-summary:${user.id}`, + `medication-compliance-summary:${actor.id}`, 30, 60_000, ); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 2bef4d0f4..fd2841b4d 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -44,9 +44,7 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), @@ -76,6 +74,7 @@ vi.mock("next/headers", () => ({ })); import { GET, POST } from "../route"; +import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { @@ -92,6 +91,10 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // v1.36.0 — the GET's validation breadcrumb goes through the `auditLog` + // helper, whose promise the route attaches a `.catch` to. `resetAllMocks` + // clears the factory's implementation, so it is restored here. + vi.mocked(auditLog).mockResolvedValue(undefined); // v1.4.34 IW-G — reset compliance LRU between tests so each case // observes a cold cache. __resetAllCachesForTests(); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 336ee1a19..deb226425 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/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, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -84,7 +84,7 @@ const updateSchema = z.object({ }); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); const parsed = querySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), @@ -102,17 +102,16 @@ export const GET = apiHandler(async (request: NextRequest) => { const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "medications.intake.list.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); + // v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only thing that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a delegate's malformed query would read as the owner's own. + void auditLog("medications.intake.list.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/medications/route.ts b/src/app/api/medications/route.ts index b7c7c321e..5f9ecd6ae 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -33,7 +33,7 @@ import { NextRequest } from "next/server"; const APPLE_HEALTH_MIRROR_CAP = 30; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read"); // Cache the list shape on userId. The 60 s fresh TTL bounds the // cross-midnight staleness window for `todayEventCount` and the diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 4704225f6..3b42bc7ed 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1366,7 +1366,9 @@ describe("GET /api/cycle/profile", () => { const response = await call(GET as Handler, "/api/cycle/profile"); expect(response.status).toBe(200); - expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + expect((await body(response)).data).toMatchObject({ + cycleTrackingEnabled: true, + }); }); it("refuses when the RECORD does not track a cycle, whatever the caller does", async () => { @@ -1402,7 +1404,9 @@ describe("GET /api/cycle/profile", () => { const { GET } = await import("@/app/api/cycle/profile/route"); const response = await call(GET as Handler, "/api/cycle/profile"); expect(response.status).toBe(200); - expect((await body(response)).data).toMatchObject({ cycleTrackingEnabled: true }); + expect((await body(response)).data).toMatchObject({ + cycleTrackingEnabled: true, + }); }); }); @@ -1463,3 +1467,292 @@ describe("GET /api/cycle/day-logs and /api/cycle/symptoms/custom", () => { }); }); }); + +/* -------------------------------------------------------------------------- */ +/* Medications */ +/* -------------------------------------------------------------------------- */ + +async function seedMedication(userId: string, name: string) { + const medication = await getPrismaClient().medication.create({ + data: { userId, name, dose: "10mg" }, + }); + await getPrismaClient().medicationSchedule.create({ + data: { + medicationId: medication.id, + windowStart: "08:00", + windowEnd: "10:00", + }, + }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date("2026-07-14T08:00:00Z"), + takenAt: new Date("2026-07-14T08:12:00Z"), + }, + }); +} + +contract("GET /api/medications", { + call: async () => { + const { GET } = await import("@/app/api/medications/route"); + return call(GET as Handler, "/api/medications"); + }, + seed: seedMedication, + read: (payload: { name: string }[]) => payload.map((m) => m.name), + ownerMarker: "owner-statin", + delegateMarker: "delegate-statin", +}); + +/** Today's dose, so the `scope=today` window actually contains it. */ +async function seedTodaysDose(userId: string, name: string) { + const medication = await getPrismaClient().medication.create({ + data: { userId, name, dose: "10mg" }, + }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: new Date(), + }, + }); + return medication; +} + +describe("GET /api/medications/intake", () => { + async function today(): Promise { + const { GET } = await import("@/app/api/medications/intake/route"); + return call(GET as Handler, "/api/medications/intake?scope=today"); + } + + it("lists the owner's doses and none of the delegate's", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ownerMed = await seedTodaysDose(owner.id, "owner-statin"); + const delegateMed = await seedTodaysDose(delegate.id, "delegate-statin"); + + const { grant } = await switchInto(owner.id, delegate.id); + const response = await today(); + expect(response.status).toBe(200); + const ids = ((await body(response)).data as { medicationId: string }[]).map( + (e) => e.medicationId, + ); + expect(ids).toContain(ownerMed.id); + expect(ids).not.toContain(delegateMed.id); + + await revokeGrant(grant.id); + await expectDenied(await today()); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await seedTodaysDose(owner.id, "owner-statin"); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + await expectDenied(await today()); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + const med = await seedTodaysDose(plain.id, "plain-statin"); + await signIn(plain.id); + + const response = await today(); + expect(response.status).toBe(200); + const ids = ((await body(response)).data as { medicationId: string }[]).map( + (e) => e.medicationId, + ); + expect(ids).toContain(med.id); + }); +}); + +describe("the per-medication reads", () => { + it("resolve the ownership guard against the record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + await seedMedication(delegate.id, "delegate-statin"); + const ownerMed = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const delegateMed = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: delegate.id }, + }); + + const detail = await import("@/app/api/medications/[id]/route"); + const cadence = await import("@/app/api/medications/[id]/cadence/route"); + const revisions = + await import("@/app/api/medications/[id]/schedule-revisions/route"); + const sideEffects = + await import("@/app/api/medications/[id]/side-effects/route"); + const inventory = + await import("@/app/api/medications/[id]/inventory/route"); + const phaseConfig = + await import("@/app/api/medications/[id]/phase-config/route"); + const intake = await import("@/app/api/medications/[id]/intake/route"); + + const { grant } = await switchInto(owner.id, delegate.id); + + const mine = await call( + detail.GET as Handler, + `/api/medications/${ownerMed.id}`, + { id: ownerMed.id }, + ); + expect(mine.status).toBe(200); + expect((await body(mine)).data).toMatchObject({ name: "owner-statin" }); + + // Every sibling reaches the owner's medication... + for (const route of [ + cadence.GET, + revisions.GET, + sideEffects.GET, + inventory.GET, + phaseConfig.GET, + intake.GET, + ]) { + const response = await call( + route as Handler, + `/api/medications/${ownerMed.id}/sub`, + { id: ownerMed.id }, + ); + expect(response.status).toBe(200); + } + + // ...and none of them reaches the delegate's own, because + // `assertMedicationOwnership` now asks about the record. + for (const route of [ + detail.GET, + cadence.GET, + revisions.GET, + sideEffects.GET, + inventory.GET, + phaseConfig.GET, + intake.GET, + ]) { + const response = await call( + route as Handler, + `/api/medications/${delegateMed.id}/sub`, + { id: delegateMed.id }, + ); + expect(response.status).toBe(404); + } + + await revokeGrant(grant.id); + for (const route of [detail.GET, cadence.GET, intake.GET]) { + await expectDenied( + await call(route as Handler, `/api/medications/${ownerMed.id}/sub`, { + id: ownerMed.id, + }), + ); + } + }); + + it("are unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + await seedMedication(plain.id, "plain-statin"); + const med = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: plain.id }, + }); + await signIn(plain.id); + + const detail = await import("@/app/api/medications/[id]/route"); + const response = await call( + detail.GET as Handler, + `/api/medications/${med.id}`, + { id: med.id }, + ); + expect(response.status).toBe(200); + expect((await body(response)).data).toMatchObject({ + name: "plain-statin", + }); + }); + + it("refuses a caller who names a record they were never granted", async () => { + const owner = await makeUser("owner"); + const stranger = await makeUser("stranger"); + await seedMedication(owner.id, "owner-statin"); + const med = await getPrismaClient().medication.findFirstOrThrow({ + where: { userId: owner.id }, + }); + + const session = await signIn(stranger.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + const detail = await import("@/app/api/medications/[id]/route"); + await expectDenied( + await call(detail.GET as Handler, `/api/medications/${med.id}`, { + id: med.id, + }), + ); + }); +}); + +describe("GET /api/medications/compliance — the bucket follows the actor", () => { + it("spends the delegate's quota, not the owner's", async () => { + // The one delegable read that costs a quota. If the bucket keyed on the + // resolved user, a delegate could lock the owner out of their own + // cabinet, and could collect a fresh allowance by switching records. The + // rate_limits row is read from the table rather than inferred from a 429, + // because a key that is merely wrong still produces a 200. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + + await switchInto(owner.id, delegate.id); + const { GET } = await import("@/app/api/medications/compliance/route"); + const response = await call(GET as Handler, "/api/medications/compliance"); + expect(response.status).toBe(200); + + const keys = ( + await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-compliance-summary:" } }, + }) + ).map((row) => row.key); + expect(keys).toEqual([`medication-compliance-summary:${delegate.id}`]); + expect(keys).not.toContain(`medication-compliance-summary:${owner.id}`); + }); + + it("still keys on the caller when nobody has switched", async () => { + const plain = await makeUser("plain"); + await seedMedication(plain.id, "plain-statin"); + await signIn(plain.id); + + const { GET } = await import("@/app/api/medications/compliance/route"); + expect( + (await call(GET as Handler, "/api/medications/compliance")).status, + ).toBe(200); + + const keys = ( + await getPrismaClient().rateLimit.findMany({ + where: { key: { startsWith: "medication-compliance-summary:" } }, + }) + ).map((row) => row.key); + expect(keys).toEqual([`medication-compliance-summary:${plain.id}`]); + }); + + it("refuses the next request after the grant is revoked", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await seedMedication(owner.id, "owner-statin"); + + const { GET } = await import("@/app/api/medications/compliance/route"); + const { grant } = await switchInto(owner.id, delegate.id); + expect( + (await call(GET as Handler, "/api/medications/compliance")).status, + ).toBe(200); + + await revokeGrant(grant.id); + await expectDenied( + await call(GET as Handler, "/api/medications/compliance"), + ); + }); +}); From 81e5c7464b18447f041f7dc90809ee9cb6660bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 17:36:44 +0200 Subject: [PATCH 7/8] test(sharing): point the refusal arm at a route that can never be delegable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/measurements` was the resolver test's example of a route refusing under a switch. It is delegable now, so that arm was asserting the opposite of what ships and went red on the migration — which is the arm doing its job, not a defect in it. Split in two. The measurements route keeps both of its real claims: unchanged un-switched, and the owner's rows and not the delegate's once switched. The refusal moves to `GET /api/share-links`, which is non-delegable permanently and by design — a delegate who could mint a clinician link would hold a door that survives the owner revoking their access. A route that can never join the frozen list cannot break this arm the same way twice. --- .../acting-account-resolver.test.ts | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index a718a7bdd..d2fb75884 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -15,15 +15,24 @@ * into the second, and the owner's revoke button would mean "at some point * later" instead of "now". * - * The do-no-harm arm runs against a REAL shipped route (`GET /api/measurements`, - * a bare-`requireAuth` handler nobody has migrated). A mock cannot fake that, - * and the arm is worth nothing unless the route it exercises is one somebody - * actually ships. + * Both arms run against REAL shipped routes, and since the route migration they + * exercise both sides of the boundary rather than one: * - * The delegable handler is assembled here rather than imported because no route - * declares `requireRecordAuth` yet — S3 lands the frozen list empty on purpose, - * and S6 migrates the first route into it. Everything under it is real: the - * real `apiHandler`, the real resolver, the real grant module, the real + * * `GET /api/measurements` is delegable now. Un-switched it must answer + * exactly what it answered before the resolver existed, which is the + * do-no-harm claim and the one worth the most. + * * `GET /api/share-links` is refused, permanently and by design — a delegate + * must never mint a door that outlives their own revocation. It is what a + * route that never declared a mode does under a switch. Naming a + * permanently-refused route rather than a merely-unmigrated one is + * deliberate: this arm broke once already when the route it pointed at + * became delegable, and a route that can never join the list cannot break + * it the same way twice. + * + * The delegable handler below is still assembled here rather than imported. A + * shipped route would work, but it would bind this file to one domain's query + * shape, and what is under test is the resolver. Everything beneath it is real: + * the real `apiHandler`, the real resolver, the real grant module, the real * database. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -276,7 +285,7 @@ describe("acting-account resolver — the decision is never cached", () => { }); describe("acting-account resolver — do no harm", () => { - it("leaves an un-switched request on a real shipped route exactly as it was", async () => { + it("leaves an un-switched request on a real delegable route exactly as it was", async () => { const user = await makeUser("solo"); await seedWeight(user.id, 72); await signIn(user.id); @@ -292,7 +301,7 @@ describe("acting-account resolver — do no harm", () => { ).toEqual([72]); }); - it("refuses that same route while a switch is active, and serves nobody's rows", async () => { + it("serves the owner's rows on that route once the caller has switched", async () => { const { owner, delegate } = await household(); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); @@ -301,13 +310,32 @@ describe("acting-account resolver — do no harm", () => { const response = await GET( new NextRequest("http://localhost/api/measurements?type=WEIGHT"), ); + expect(response.status).toBe(200); + const body = await response.json(); + // The owner's 81 kg, and — the failure that matters — not the delegate's + // own 64 kg, which is seeded alongside it. + expect( + body.data.measurements.map((m: { value: number }) => m.value), + ).toEqual([81]); + }); + + it("refuses a route that never declared a mode, and serves nobody's rows", async () => { + // `GET /api/share-links` is permanently non-delegable: a delegate who + // could mint a clinician link would hold a door that survives the owner + // revoking their access. It resolves through the bare `requireAuth` + // default arm, which is what makes the refusal structural rather than a + // check somebody remembered to write. + const { owner, delegate } = await household(); + const session = await signIn(delegate.id); + await switchTo(session.id, owner.id); + + const { GET } = await import("@/app/api/share-links/route"); + const response = await GET(); expect(response.status).toBe(403); const body = await response.json(); expect(body.meta.errorCode).toBe("sharing.not_permitted"); expect(body.data).toBeNull(); - // Not the owner's rows, and — the failure that matters — not the - // delegate's own either. - expect(JSON.stringify(body)).not.toContain("64"); + expect(owner.id).not.toBe(delegate.id); }); }); From e449a19e55e742239cf292377a1b4ac61bfc858b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 18:17:05 +0200 Subject: [PATCH 8/8] docs(sharing): say how the cycle gate actually resolves the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two cycle entries in the frozen list claimed the gate reads `gender` off the resolved auth context. It does not: `requireCycleEnabled` names that parameter `_gender` and ignores it, then re-loads gender and the profile by the resolved user id through the module gate. The answer is the same — the owner's — but a reviewer checking the claim would have found a dead argument and no explanation. The profile route is the exception and now says so: it passes the auth-context gender to `isCycleEnabled` itself to compute the flag it returns. A frozen-list entry is only worth the reviewing it invites, so it has to describe the mechanism that is there rather than the one the call site looks like it has. --- src/__tests__/delegable-surface-guard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 603afeea5..7681bcfa3 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -312,11 +312,11 @@ const DELEGABLE_ROUTES: Record = { "app/api/workouts/route.ts": "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", "app/api/cycle/cycles/route.ts": - "The record's cycle history. The gate reads `user.gender` off the resolved record, which is the point: whether cycle tracking applies is a property of the record, not of whoever is looking at it.", + "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", "app/api/cycle/day-logs/route.ts": 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', "app/api/cycle/profile/route.ts": - "The record's cycle profile, supplied by the gate itself. No query of its own.", + "The record's cycle profile, supplied by the gate itself. This is the one cycle route that does read the auth-context gender directly — `isCycleEnabled(user.gender, gate.profile)` computes the `resolved` flag it returns — and post-substitution that is the owner's.", "app/api/cycle/symptoms/custom/route.ts": "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", "app/api/medications/route.ts":