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