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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 16:57:07 +02:00
1 parent 10f9a83356
commit fe765a7ae2
6 files changed
+165 -9

No files matched your search

@@ -308,6 +308,14 @@ const DELEGABLE_ROUTES: Record<string, string> = {
"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<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 = 31;
const FROZEN_ENTRY_COUNT = 35;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+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, 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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
@@ -1315,3 +1315,151 @@ contract<number>("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<string>("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,
});
});
});