fix(cycle): fence the phase crosstab's outcome columns to the sections they belong to

`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.

The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.

The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.

`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:36:35 +02:00
1 parent 036720bcfb
commit e645921852
12 files changed
+375 -88

No files matched your search

+51 -29
View File
@@ -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,
},
});
+2 -4
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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");
+2 -4
View File
@@ -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 }> };
@@ -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 }> };
+1 -1
View File
@@ -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 }> };