diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index d23d5c1da..287406817 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -16,6 +16,7 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -50,7 +51,7 @@ const WINDOW_DAYS = 365; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "cycle"); + const { user, grantId } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -68,6 +69,19 @@ export const GET = apiHandler(async () => { const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); + // The crosstab is a cycle surface that reads two other sections. The phase + // labels are cycle data; the outcome columns are not. `lutealAvg`, + // `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration, + // steps, weight, temperature, glucose and the mood score in display units, + // and a grant scoped to the cycle alone was never consent for any of them. + // So the two foreign channels are fenced at the query, not at the response: + // a section the caller is outside of is never read, so nothing downstream + // can leak it back — the rows simply do not exist, exactly as they do not + // for a record with no measurements. + const visible = await actingDomainVisibility(prisma, grantId); + const seesMeasurements = visible("measurements"); + const seesMind = visible("mind"); + const [ cycles, dayLogRows, @@ -116,25 +130,28 @@ export const GET = apiHandler(async () => { select: { measuredAt: true, value: true }, }), // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, - }), + // canonical-source deduped per day inside `metricDayMap`. Skipped whole + // for a caller outside `measurements`. + seesMeasurements + ? prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }) + : [], prisma.user.findUnique({ where: { id: user.id }, select: { sourcePriorityJson: true }, @@ -142,15 +159,18 @@ export const GET = apiHandler(async () => { // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a // Measurement row, so read it here and inject it into the crosstab as a // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). - prisma.moodEntry.findMany({ - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }), + // Skipped whole for a caller outside `mind`. + seesMind + ? prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }) + : [], ]); // The latest open cycle runs to the predicted next-period start so the @@ -239,6 +259,8 @@ export const GET = apiHandler(async () => { lagged_discovered: lagged.discovered.length, lagged_pairs_tested: lagged.pairsTested, symptom_patterns: symptomPatterns.length, + measurements_visible: seesMeasurements, + mind_visible: seesMind, }, }); diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index 076ec4547..c22af968e 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -27,10 +27,8 @@ import { } from "@/lib/api-response"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadEncounterLinks, -} from "@/lib/encounters/service"; +import { loadEncounterLinks } from "@/lib/encounters/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index c37cd9500..73c5731fd 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, loadEncounterLinks, resolveOwnerNotificationContext, shouldHaveReminder, } from "@/lib/encounters/service"; import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index b33bc64d9..930e04f4f 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, findOwnAppointmentReminderId, @@ -54,6 +53,7 @@ import { type RestoreSkipLog, } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..9946128be 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, loadEncounterLinksForMany, closeCheckupForVisit, @@ -50,6 +49,7 @@ import { summarizeRestoreSkips, type RestoreSkipLog, } from "@/lib/export/restore-skips"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; export const GET = apiHandler(async (request: NextRequest) => { const { user, grantId } = await requireRecordAuth("read", "profile"); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index 7a9b10442..d8ba5cbed 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -26,10 +26,8 @@ import { } from "@/lib/api-response"; import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadVaccinationDocuments, -} from "@/lib/vaccinations/service"; +import { loadVaccinationDocuments } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts index 685e61657..0d5600b60 100644 --- a/src/app/api/vaccinations/[id]/restore/route.ts +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, loadVaccinationDocuments, resolveSeriesFor, } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 8a7c77bf2..1b85c023d 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, applyVaccinationLinks, loadVaccinationDocuments, resolveOwnedEncounter, @@ -40,6 +39,7 @@ import { resolveSeriesFor, } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 705596437..7f080ca56 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -18,7 +18,6 @@ import { replaceTargets, type LinkTargetKind, } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { @@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = { reminder: { select: { nextDueAt: true } }, } as const satisfies Prisma.EncounterInclude; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * Which section of the record each link family's TARGET belongs to. * diff --git a/src/lib/sharing/acting-domains.ts b/src/lib/sharing/acting-domains.ts new file mode 100644 index 000000000..c8bb830d5 --- /dev/null +++ b/src/lib/sharing/acting-domains.ts @@ -0,0 +1,32 @@ +/** + * Which sections of the record the caller is actually inside. + * + * Lives here rather than beside one feature because three unrelated seams ask + * it — a visit's link labels, a vaccination's, and the cycle crosstab's + * outcome columns — and a route that reads across its own declared section is + * a shape the product will keep growing. Two verbatim copies of it already + * existed in the visits and vaccination services; a third would have been the + * one that drifted. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 8d929ff5f..8d2212bd2 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { listTargets, replaceTargets } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; import { @@ -37,29 +36,6 @@ export const SERIES_SELECT = { seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. Same shape as the visits service, deliberately - * — the two features sit on the same seam and a second spelling of this would - * be a second thing to keep in step. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * The whole live history for one account, in the shape the derivation wants. * diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts new file mode 100644 index 000000000..da1cf82d9 --- /dev/null +++ b/tests/integration/cycle-insights-scoped-delegate.test.ts @@ -0,0 +1,283 @@ +/** + * What a cycle-only delegate learns from `GET /api/cycle/insights`. + * + * The route is declared `("read", "cycle")`, and everything about the phase + * labels belongs there. Its outcome columns do not: `lutealAvg`, + * `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps, + * weight, temperature, glucose and the mood score in display units, and those + * are `measurements` and `mind` data reached through a cycle grant. + * + * So two properties, both about somebody holding `["cycle"]` and nothing else: + * + * 1. **No foreign figure crosses the seam.** No row whose `metricKey` names + * a measurement channel, no mood row, and no lagged discovery built out + * of either. + * 2. **The cycle-native answer still arrives.** The symptom-by-phase + * patterns are cycle data and stay, so the fix is a fence rather than a + * blanket refusal. + * + * The unscoped grant is asserted in the same fixture, because a change that + * hid the figures from everyone would satisfy the first property and break + * the feature. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "cycle-insights-owner"; +const DELEGATE_ID = "cycle-insights-delegate"; + +/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */ +const CYCLE_LENGTH = 28; +const PERIOD_LENGTH = 5; +const OBSERVED_CYCLES = 5; + +function isoDay(offsetDays: number): string { + const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); +} + +function atNoon(day: string): Date { + return new Date(`${day}T12:00:00Z`); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto(scope: ShareDomain[] | null) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +/** + * Five observed cycles, then a weight and a mood value on every labelled day + * whose size depends on the phase. + * + * The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the + * Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is + * missing: with this fixture a missing row means the fence removed it. + */ +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `cycle-insights-${name}`, + email: `cycle-insights-${name}@example.test`, + gender: "FEMALE", + timezone: "UTC", + }, + }); + } + + // Prediction off, so the phase map the route builds is the one this fixture + // can reproduce from the logged cycles alone. + await prisma.cycleProfile.create({ + data: { + userId: OWNER_ID, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + + const starts: string[] = []; + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + starts.push(isoDay(-i * CYCLE_LENGTH)); + } + for (const [index, startDate] of starts.entries()) { + const next = starts[index + 1] ?? null; + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate, + endDate: next + ? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH) + : null, + periodEndDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1, + ), + ovulationDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15, + ), + tz: "UTC", + }, + }); + } + + const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter"); + const cycles = await prisma.menstrualCycle.findMany({ + where: { userId: OWNER_ID }, + orderBy: { startDate: "asc" }, + }); + const today = isoDay(0); + const phaseByDay = buildPhaseDayMap( + cycles, + null, + 14, + isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH), + today, + today, + ); + + let luteal = 0; + let follicular = 0; + for (const [day, phase] of phaseByDay) { + if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue; + const high = phase === "LUTEAL"; + if (high) luteal++; + else follicular++; + // A little jitter in both channels, so the Welch test has a variance to + // work with and the row is not dropped for a reason the fence did not + // cause. + const jitter = ((luteal + follicular) % 3) * 0.1; + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "WEIGHT", + value: (high ? 80 : 70) + jitter, + unit: "kg", + measuredAt: atNoon(day), + }, + }); + await prisma.moodEntry.create({ + data: { + userId: OWNER_ID, + mood: high ? "SCHLECHT" : "GUT", + score: (high ? 1 : 4) + ((luteal + follicular) % 2), + date: day, + moodLoggedAt: atNoon(day), + tz: "UTC", + }, + }); + } + + expect( + luteal, + "the fixture labels enough luteal days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); + expect( + follicular, + "the fixture labels enough follicular days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); +} + +interface InsightsBody { + rows: Array<{ metricKey: string }>; + headline: { metricKey: string } | null; + lagged: { discovered: unknown[] }; + symptomPatterns: unknown[]; +} + +async function readInsights(): Promise { + const { GET } = await import("@/app/api/cycle/insights/route"); + // The handler declares no parameters; the wrapper still reads the request + // off its first argument, and the method it carries is what the record + // resolver escalates on. + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: InsightsBody }; + return parsed.data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecord(); +}); + +describe("cycle insights fence the sections it reads across", () => { + it("gives the owner the measurement and the mood contrast", async () => { + await signIn(OWNER_ID); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys, "the weight contrast is the fixture's whole point").toContain( + "weight", + ); + expect(keys, "and the mood contrast beside it").toContain("mood"); + }); + + it("gives an entire-record delegate the same figures", async () => { + await switchInto(null); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys).toContain("weight"); + expect(keys).toContain("mood"); + }); + + it("gives a cycle-only delegate no measurement and no mood figure", async () => { + await switchInto(["cycle"]); + const body = await readInsights(); + + expect( + body.rows, + "every crosstab row is a measurement or a mood column", + ).toHaveLength(0); + expect(body.headline).toBeNull(); + expect( + body.lagged.discovered, + "the lagged matrix is built from the same two sections", + ).toHaveLength(0); + }); + + it("gives a cycle+mind delegate the mood figure and not the weight", async () => { + await switchInto(["cycle", "mind"]); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect( + keys, + "mind is held, so the mood column is within the grant", + ).toContain("mood"); + expect(keys, "measurements is not held").not.toContain("weight"); + }); +});