feat(sharing): open the sleep, nutrient, illness and workout reads

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 16:52:59 +02:00
1 parent 8c070c953b
commit 10f9a83356
10 files changed
+297 -17

No files matched your search

+17 -1
View File
@@ -292,6 +292,22 @@ const DELEGABLE_ROUTES: Record<string, string> = {
"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<string, string> = {
* 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` —
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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, number>): 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;
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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
@@ -1051,3 +1051,267 @@ contract<number>("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<number>("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<number>("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<string>("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<number>("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,
});