mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
8c070c953b
commit
10f9a83356
10 files changed
+297
-17
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user