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