Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 01:53:18 +02:00
commit aed541910c
17 files changed
+1083 -41

No files matched your search

@@ -1,4 +1,4 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
@@ -38,13 +38,7 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* `coach-route-gate-inventory.test.ts`; listed here so
* coach-bearing routes are not flagged as ungated.
*
* 3. BUILDER-GATED — the route delegates its whole payload to an
* aggregator that resolves `resolveModuleMap(userId)` once and
* excludes disabled-module sections/resources from the build
* (`collectDoctorReportData`, `loadFhirContext`). The exclusion is
* structural at the builder, so the route carries no per-key call.
*
* 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
* reason at the entry. The two reasons in play:
* - DATA LAYER: raw CRUD over the domain's own rows. Writing or
@@ -56,9 +50,30 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* - INFRA / UI-ONLY: settings, availability probes, and the static
* FHIR CapabilityStatement carry no module data.
*
* Anything that doesn't fit one of the four buckets is an orphan and
* Anything that doesn't fit one of the three buckets is an orphan and
* fails the test BY NAME so the fix is one search-and-add: gate the route
* or move it onto the EXEMPT allowlist with a documented reason.
*
* WHAT THIS TEST DOES NOT PROVE, AND WHO PROVES IT
* -----------------------------------------------
* This file is a DISCOVERY test: it proves no route escapes classification.
* It cannot prove a gate actually refuses, because it only reads source text.
*
* That distinction used to be blurred. A fourth "BUILDER-GATED" bucket
* accepted the mere MENTION of an aggregator's name (`loadFhirContext`,
* `collectDoctorReportData`) as proof that the route was gated. It was not
* proof of anything: `loadFhirContext` filtered per-domain modules but never
* evaluated `doctorReport`, so all five `/api/fhir/*` data routes sat in a
* green bucket while serving the whole record — including the decrypted
* insurance number — to an account that had the doctor-report module off.
* The bucket is gone. A route is now only counted as gated when it CALLS a
* gate, and `REQUIRED_TREE_MODULE` below pins which key it must name.
*
* The behaviour itself — a disabled module actually producing a 403 or an
* omission — is proven by the per-surface tests listed in
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
* deleting the behavioural coverage fails here with a pointer to what was
* lost rather than silently leaving only a text-match behind.
*/
/**
@@ -99,9 +114,10 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/medications",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
// availability probe) was orphaned dead code (no production caller) and
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
// which gates on the `doctorReport` module directly AND through the
// `collectDoctorReportData` builder.
// removed. The doctor-report surface is `/api/export/health-record` plus
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
// routes previously relied on the removed builder-mention bucket and were
// in practice ungated — see the header note.
"src/app/api/fhir",
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
// correlations / derived / narrative / pregenerate / rhythm-events route
@@ -273,12 +289,55 @@ const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
// are not flagged as ungated.
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
// Builder aggregators that resolve `resolveModuleMap` once and exclude
// disabled-module sections/resources at the build boundary.
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
"collectDoctorReportData",
"loadFhirContext",
];
/**
* Trees whose routes must gate on ONE specific module key. Naming the key
* here means a future route in the tree cannot satisfy the inventory by
* gating on some other, more permissive module — the failure mode that a
* generic "is there any gate?" check cannot see.
*
* `/api/fhir` is the entry that matters: every data route there serves the
* whole-record doctor-report aggregate, so `doctorReport` is the only
* correct key.
*/
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
"src/app/api/fhir": "doctorReport",
};
/**
* Surfaces whose module behaviour is proven by a real behavioural test
* (module off ⇒ 403 or omission; module on ⇒ served). Listed so that
* deleting the proof fails the inventory instead of quietly downgrading the
* guarantee back to a text match.
*
* The three aggregate surfaces below are the ones that re-served per-domain
* data ungated: two of them (`/api/sync/changes`, `/api/dashboard/summary`)
* are cross-domain feeds that live outside `MODULE_ROUTE_TREES` entirely, so
* without this list nothing in the inventory would notice them at all.
*/
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
[
{
path: "src/app/api/fhir/__tests__/module-gate.test.ts",
proves:
"every /api/fhir data route 403s with module.disabled when doctorReport is off",
},
{
path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
proves:
"loadFhirContext itself refuses to assemble the record when doctorReport is off",
},
{
path: "src/app/api/sync/changes/__tests__/module-gate.test.ts",
proves:
"the sync delta feed omits cycleDays/cycles (and never queries them) when cycle is off",
},
{
path: "src/app/api/dashboard/summary/__tests__/module-gate.test.ts",
proves:
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
},
];
/**
* True when the file contains the needle on a line that is NOT a pure
@@ -373,7 +432,7 @@ describe("module API route gate inventory", () => {
}
});
it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => {
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
const routes = findAllModuleRouteFiles();
expect(routes.length).toBeGreaterThan(0);
@@ -387,15 +446,16 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
if (exempt.has(path)) continue;
orphans.push({
path,
reason:
'no module gate found — add requireModuleEnabled("<key>"), a delegated gate, ' +
"a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason",
'no module gate found — add requireModuleEnabled("<key>") or a delegated gate, ' +
"or move the route onto EXEMPT_ROUTES with a documented reason. " +
"Importing a module-aware builder is NOT a gate: a builder can filter some " +
"modules and never evaluate yours.",
});
}
@@ -432,6 +492,55 @@ describe("module API route gate inventory", () => {
).toEqual([]);
});
it("routes in a key-pinned tree gate on that tree's module key", () => {
// A generic "is there any gate?" check cannot tell `doctorReport` from
// some other, more permissive key. Where a whole tree serves exactly one
// module, name it.
const wrong: Array<{ path: string; found: string[] }> = [];
for (const [tree, requiredKey] of Object.entries(REQUIRED_TREE_MODULE)) {
for (const path of findRouteFiles(tree)) {
if (EXEMPT_ROUTES.includes(path)) continue;
const text = readFileSync(resolve(repoRoot, path), "utf8");
const { keys } = extractModuleKeys(text);
if (!keys.has(requiredKey)) {
wrong.push({ path, found: [...keys] });
}
}
}
expect(
wrong,
[
"Routes that must gate on their tree's module key but do not:",
...wrong.map(
(w) =>
` - ${w.path}: found [${w.found.join(", ") || "no literal key"}]`,
),
].join("\n"),
).toEqual([]);
});
it("the behavioural gate tests that prove refusal still exist", () => {
// This inventory only reads source text. The listed files are what
// actually exercise a disabled module end to end; losing one silently
// downgrades the guarantee back to a text match, which is precisely the
// failure this test was rewritten to prevent.
const missing = BEHAVIOURAL_GATE_TESTS.filter(
(t) => !existsSync(resolve(repoRoot, t.path)),
);
expect(
missing,
[
"Behavioural module-gate coverage has gone missing.",
"This inventory cannot prove a gate refuses — these tests do:",
...missing.map((m) => ` - ${m.path}\n proves: ${m.proves}`),
"Restore the file, or drop the entry and say what replaces it.",
].join("\n"),
).toEqual([]);
});
it("EXEMPT_ROUTES does not reference deleted route files", () => {
const known = new Set(findAllModuleRouteFiles());
const stale = EXEMPT_ROUTES.filter((p) => !known.has(p));
@@ -457,8 +566,7 @@ describe("module API route gate inventory", () => {
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
fileHasCall(text, ILLNESS_GATE_NEEDLE)
) {
stillGated.push(path);
}
@@ -0,0 +1,212 @@
/**
* `GET /api/dashboard/summary` — module gate.
*
* This route builds its own payload instead of going through
* `readDashboardSnapshotCached`, so it never picked up the gate the shared
* snapshot builder applies: it emitted glucose and sleep cards on data
* presence alone while `/api/dashboard/snapshot` correctly withheld them for
* the very same account.
*
* OMIT, not 403 — the payload spans ten metric cards across several domains,
* and blanking an iOS dashboard because one module is off would be the wrong
* trade. Core vitals must keep flowing.
*
* Behavioural: the assertions read the emitted card list, so reverting the
* filter in the route turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The gate resolver runs for real; only its data sources are stubbed, so the
// module state is driven the way production drives it.
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return { ...actual, getAssistantFlags: vi.fn() };
});
// The heavy builder is stubbed: this test pins the GATE, not the aggregate
// (which has its own coverage). It returns one card per emitted kind.
vi.mock("@/lib/cache/server-cache", () => ({
caches: { analytics: {} },
cachedSwr: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { getAssistantFlags } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
locale: "en",
timezone: "UTC",
},
};
/** Every card kind the builder can emit, one card each. */
const ALL_KINDS = [
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"glucose",
"sleep",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
] as const;
function card(kind: string) {
return {
id: kind,
kind,
titleKey: `dashboard.metric.title.${kind}`,
latestValue: 1,
secondaryValue: null,
unitKey: `dashboard.metric.unit.${kind}`,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: "flat",
sparkline: [1],
updatedAt: new Date().toISOString(),
allTimeCount: 5,
lastSeenAt: new Date().toISOString(),
};
}
/** Drive the real gate through the persisted disabled-allowlist. */
function setDisabledModules(disabled: string[]): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
gender: null,
disableCoach: false,
modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])),
} as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
async function emittedKinds(): Promise<string[]> {
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Array<{ kind: string }> };
};
return body.data.metrics.map((m) => m.kind);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
vi.mocked(cachedSwr).mockImplementation((async () => ({
greeting: { salutation: "hi", date: new Date().toISOString() },
streak: { currentDays: 0, longest: 0, label: "" },
compliance: { scheduledToday: 0, takenToday: 0 },
highlightInsight: null,
metrics: ALL_KINDS.map(card),
sleepRhythm: null,
lastUpdated: new Date().toISOString(),
})) as never);
});
describe("GET /api/dashboard/summary — module gate", () => {
it("emits every card when no module is disabled", async () => {
setDisabledModules([]);
expect(await emittedKinds()).toEqual([...ALL_KINDS]);
});
it("drops the glucose card when the glucose module is off", async () => {
setDisabledModules(["glucose"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("glucose");
// Everything else survives — proves an omission, not a refusal.
expect(kinds).toContain("weight");
expect(kinds).toContain("sleep");
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
});
it("drops the sleep card when the sleep module is off", async () => {
setDisabledModules(["sleep"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("sleep");
expect(kinds).toContain("glucose");
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
});
it("drops both when both modules are off", async () => {
setDisabledModules(["glucose", "sleep"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("glucose");
expect(kinds).not.toContain("sleep");
expect(kinds).toHaveLength(ALL_KINDS.length - 2);
});
it("never drops a core vital, whatever is disabled", async () => {
// Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship.
setDisabledModules([...MODULE_KEYS]);
const kinds = await emittedKinds();
for (const core of [
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
]) {
expect(kinds).toContain(core);
}
});
it("gates the cached body, so a toggle takes effect on the next request", async () => {
// The analytics LRU is not evicted by a module toggle. The filter must
// therefore run on the way out, not inside the cached build.
setDisabledModules([]);
expect(await emittedKinds()).toContain("glucose");
// Same cached body, module now off.
setDisabledModules(["glucose"]);
expect(await emittedKinds()).not.toContain("glucose");
});
});
@@ -1,6 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// Every module is on for the aggregate assertions below; the gate's own
// behaviour (which cards a disabled module drops) is pinned in the sibling
// `module-gate.test.ts`.
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () =>
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
+37 -1
View File
@@ -44,6 +44,12 @@
* subsequent iOS polls inside the window hit memory. Invalidation runs
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
* `invalidateUserMedications` hooks.
*
* Module gate: the emitted `metrics` array is filtered against the account's
* module map before it leaves the handler, so a card whose measurement type
* belongs to a disabled module never ships. The decision comes from the same
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
* runs OUTSIDE the cache because a module toggle does not evict it.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { resolveModuleMap } from "@/lib/modules/gate";
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -364,7 +372,35 @@ export const GET = apiHandler(async () => {
annotate,
);
return apiSuccess(body);
// ── module gate ───────────────────────────────────────────────────
// This route builds its own payload instead of going through
// `readDashboardSnapshotCached`, so it never picked up the module gate
// the shared snapshot builder applies — it emitted glucose and sleep
// tiles on data presence alone, while `/api/dashboard/snapshot` correctly
// withheld them for the same account.
//
// OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is
// an aggregate of ten metric cards across several domains; refusing the
// whole request would blank the iOS dashboard because one module is off.
// The card simply does not appear, which is exactly what a disabled module
// means on every other dashboard surface.
//
// Applied AFTER the cache read, deliberately. The analytics LRU is keyed
// `<userId>|dashboard-summary` with a 60 s fresh / 1 h stale window, and
// toggling a module does not evict it — gating inside the builder would
// leave the disabled tile served for up to an hour after the user turned
// it off. Filtering the cached body makes the toggle effective on the very
// next request.
const modules = await resolveModuleMap(user.id);
const metrics = gateMetricCardsByModules(body.metrics, modules);
annotate({
meta: {
metrics_emitted: metrics.length,
metrics_gated: body.metrics.length - metrics.length,
},
});
return apiSuccess({ ...body, metrics });
});
interface SummaryBuilderContext {
+16
View File
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -32,6 +33,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -9,6 +9,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationadministration.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationstatement.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
+16
View File
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { observationsFromReportData } from "@/lib/fhir/resources";
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.observation.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
+16
View File
@@ -9,6 +9,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { patientResource } from "@/lib/fhir/resources";
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.patient.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -0,0 +1,179 @@
/**
* `/api/fhir/*` — doctorReport module gate.
*
* The FHIR REST face serves the same whole-record aggregate as
* `/api/export/health-record`, including the decrypted insurance number on
* the Patient resource. The export gates on the `doctorReport` module; the
* FHIR routes did not, so the module could be off and `$everything` still
* returned the full Bundle to the same token.
*
* REFUSE, not omit — a whole-record export has no truthful partial answer.
* Every data route 403s with the shared `module.disabled` envelope; only the
* static CapabilityStatement at `/api/fhir/metadata` stays open (server
* metadata, no user data).
*
* Behavioural: the assertions read status codes and check that no aggregate
* was ever loaded, so removing a gate turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The module gate itself is NOT mocked — the whole point is to exercise the
// real resolver and the real 403 envelope. Only its data sources are stubbed,
// so the module state is driven the same way production drives it: through
// the persisted `modulePreferencesJson` allowlist.
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })),
coverageResource: vi.fn(() => null),
observationsFromReportData: vi.fn(() => []),
medicationStatementsFromReportData: vi.fn(() => []),
medicationAdministrationsFromReportData: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as patientGet } from "../Patient/route";
import { GET as observationGet } from "../Observation/route";
import { GET as medStatementGet } from "../MedicationStatement/route";
import { GET as medAdminGet } from "../MedicationAdministration/route";
import { GET as everythingGet } from "../$everything/route";
import { GET as metadataGet } from "../metadata/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { loadFhirContext } from "@/lib/fhir/rest";
import { prisma } from "@/lib/db";
/**
* Drive the REAL gate the way production does — through the persisted
* `modulePreferencesJson` disabled-allowlist, where only a literal `false`
* turns a module off.
*/
function setDoctorReportModule(enabled: boolean): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
gender: null,
disableCoach: false,
modulePreferencesJson: enabled ? {} : { doctorReport: false },
} as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
/** Every data route under `/api/fhir`, by the path a caller would hit. */
const DATA_ROUTES: ReadonlyArray<
[string, (req: NextRequest) => Promise<Response>]
> = [
["Patient", patientGet],
["Observation", observationGet],
["MedicationStatement", medStatementGet],
["MedicationAdministration", medAdminGet],
["$everything", everythingGet],
];
function req(path: string): NextRequest {
return new NextRequest(`http://localhost/api/fhir/${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Operator layer available for every module; the per-user layer is what
// each test drives.
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: "A123456789" },
germanAtc: false,
});
});
describe("/api/fhir/* — doctorReport module gate", () => {
describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => {
it("403s with module.disabled when the module is off", async () => {
setDoctorReportModule(false);
const res = await handler(req(path));
expect(res.status).toBe(403);
const body = (await res.json()) as {
data: unknown;
error: string;
meta?: { errorCode?: string; module?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
expect(body.meta?.module).toBe("doctorReport");
// Refused before any record was assembled — the insurance number
// never left the store.
expect(loadFhirContext).not.toHaveBeenCalled();
expect(JSON.stringify(body)).not.toContain("A123456789");
});
it("serves the Bundle when the module is on", async () => {
setDoctorReportModule(true);
const res = await handler(req(path));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain(
"application/fhir+json",
);
const bundle = (await res.json()) as { resourceType: string };
expect(bundle.resourceType).toBe("Bundle");
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
});
});
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
// Server metadata, no user data — the one FHIR route that stays open.
setDoctorReportModule(false);
const res = await metadataGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("CapabilityStatement");
});
});
+9
View File
@@ -16,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
@@ -36,6 +40,7 @@ import { GET as observationGet } from "../Observation/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
import { observationsFromReportData } from "@/lib/fhir/resources";
const SESSION_OK = {
@@ -65,6 +70,10 @@ beforeEach(() => {
count: 1,
resetAt: Date.now(),
} as never);
// The doctorReport module is ON for the contract assertions below; the
// OFF behaviour is pinned in `module-gate.test.ts`.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: null },
@@ -0,0 +1,198 @@
/**
* `GET /api/sync/changes` — cycle module gate.
*
* The delta feed multiplexes five domains over one cursor and used to serve
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
* period days and per-day symptom rows over the very token that
* `/api/cycle/*` refuses with a 403.
*
* Behavioural, not structural: the assertions read the response body and the
* Prisma call log, so reverting the gate in the route turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
},
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { isModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decodeCursor } from "@/lib/sync/cursor";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const NOW = new Date("2026-07-01T12:00:00.000Z");
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
const CYCLE_DAY_ROW = {
id: "cd-1",
userId: "user-1",
externalId: "ext-cd-1",
date: NOW,
flow: "MEDIUM",
notes: null,
notesEncrypted: null,
syncVersion: 3,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
};
const CYCLE_ROW = {
id: "mc-1",
userId: "user-1",
startDate: NOW,
endDate: null,
cycleLength: 28,
periodLength: 5,
predicted: false,
syncVersion: 2,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
};
interface FeedBody {
data: {
changes: {
measurements: { upserts: unknown[]; tombstones: unknown[] };
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
cycles: { upserts: unknown[]; tombstones: unknown[] };
};
cursor: string | null;
};
}
function req(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/sync/changes${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Non-cycle domains always return rows so a 403/empty-everything result
// cannot be mistaken for a passing "cycle omitted" assertion.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
externalId: "ext-m-1",
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: NOW,
source: "MANUAL",
notes: null,
notesEncrypted: null,
syncVersion: 1,
deletedAt: null,
updatedAt: NOW,
},
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
CYCLE_DAY_ROW,
] as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
CYCLE_ROW,
] as never);
});
describe("GET /api/sync/changes — cycle module gate", () => {
it("omits cycle rows and never queries them when the module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
// OMIT, not refuse — the other four domains must keep syncing.
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toEqual([]);
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
expect(body.data.changes.cycles.upserts).toEqual([]);
expect(body.data.changes.cycles.tombstones).toEqual([]);
// The rest of the feed is untouched — proves the empty cycle blocks are
// the gate, not a wholesale refusal.
expect(body.data.changes.measurements.upserts).toHaveLength(1);
// Not read out of Postgres at all.
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
// No symptom string anywhere in the serialised payload.
expect(JSON.stringify(body)).not.toContain("CRAMPS");
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
});
it("serves cycle rows when the module is on", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
expect(body.data.changes.cycles.upserts).toHaveLength(1);
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
});
it("gates on the cycle module key specifically", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
await GET(req());
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
});
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
// A skipped domain must not advance its watermark, so re-enabling
// resumes from where the client left off rather than skipping the rows
// that changed while the module was hidden.
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
const body = (await res.json()) as FeedBody;
const cursor = decodeCursor(body.data.cursor as string);
expect(cursor).not.toBeNull();
expect(cursor!.cycleDays).toBeUndefined();
expect(cursor!.cycles).toBeUndefined();
// The domain that did return rows advanced normally.
expect(cursor!.measurements).toBeDefined();
});
});
+52 -11
View File
@@ -37,6 +37,9 @@
* been pruned, so an incremental delta could silently miss it).
* - `syncVersion` is echoed per row so the client can keep its mirror's
* version monotonic.
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
* cycle tracking off gets both blocks empty (never a 403 — see the
* gate note in the handler) and their cursor watermarks untouched.
*
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
@@ -67,6 +70,7 @@ import {
type CycleDayLogDTO,
type MenstrualCycleDTO,
} from "@/lib/cycle/dto";
import { isModuleEnabled } from "@/lib/modules/gate";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
@@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
const serverNow = new Date();
// ── module gate: cycle ────────────────────────────────────────────
// The `cycleDays` / `cycles` domains carry period days and per-day
// symptom rows — the same data every `/api/cycle/*` route refuses with a
// 403 when the module is off. Without this the delta feed re-served them
// ungated to the very same token.
//
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
// This feed multiplexes five domains over one cursor: refusing the whole
// request would wedge measurement / mood / intake catch-up for every
// account with cycle tracking off, which (the gate delegates to
// `isCycleEnabled`) is the default for most accounts. A partial payload
// is the only outcome that keeps the client syncing while still honouring
// the promise that a disabled module hands out nothing.
//
// The two cycle watermarks are deliberately NOT advanced while the module
// is off (the queries are skipped, so `advanceCursor` sees an empty page
// and leaves the prior watermark untouched). Re-enabling therefore resumes
// from where the client actually left off instead of silently skipping the
// rows that changed while the module was hidden.
//
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
// module was turned off keeps its stale local mirror. Emitting tombstones
// for them would be wrong — a tombstone means deleted, and disabling a
// module must leave the rows intact so re-enabling finds a complete
// history. Purging a paired client's mirror needs an explicit
// "module went dark" signal in the feed contract plus client support for
// it; that is an additive wire change and an iOS-client change, not a
// server-side gate fix, so it is left for a coordinated contract cycle.
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
// The retention horizon: tombstones older than this may have been
// pruned by the cleanup job, so a cursor that predates it can no longer
// be trusted to deliver every deletion incrementally.
@@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
updatedAt: true,
},
}),
prisma.cycleDayLog.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
}),
prisma.menstrualCycle.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
}),
// Skipped outright when the module is off — not filtered after the
// fact — so the disabled rows are never read out of Postgres at all.
cycleEnabled
? prisma.cycleDayLog.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
})
: [],
cycleEnabled
? prisma.menstrualCycle.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
})
: [],
]);
const nextCursor: SyncCursor = { ...cursor };
@@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
cycle_day_tombstones: cycleDayTombstones.length,
cycle_upserts: cycleUpserts.length,
cycle_tombstones: cycleTombstones.length,
cycle_module_enabled: cycleEnabled,
has_more: hasMore,
cursor_present: Boolean(parsed.data.cursor),
},
+4 -5
View File
@@ -118,7 +118,7 @@ export {
} from "@/lib/dashboard/widget-modules";
import {
WIDGET_MODULE_BY_ID,
SUMMARY_TYPE_MODULE,
disabledSummaryTypes,
} from "@/lib/dashboard/widget-modules";
const GLUCOSE_CONTEXTS = [
@@ -965,10 +965,9 @@ function gateSummariesByModules(
summaries: Record<string, DataSummary>;
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
} {
const dropped = new Set<string>();
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
}
// The drop decision is shared with `GET /api/dashboard/summary` — see
// `disabledSummaryTypes`. Only the filtering below is snapshot-shaped.
const dropped = disabledSummaryTypes(modules);
if (dropped.size === 0) return { summaries, lastSeenByType };
const outSummaries: Record<string, DataSummary> = {};
for (const [type, summary] of Object.entries(summaries)) {
+67
View File
@@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
// on the `nutrients` module like the rest of that store's surfaces.
NUTRIENT_WATER: "nutrients",
};
/**
* The summary types a module map turns off. Lifted out of
* `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two
* dashboard aggregates that both surface per-metric data — the snapshot
* builder and the iOS `GET /api/dashboard/summary` payload — decide from
* ONE map instead of each carrying its own copy.
*
* The two callers shape their payloads differently (the snapshot keys
* summaries by `MeasurementType`; the summary route emits an array of
* metric cards keyed by an iOS `MetricKind`), so what is shared is this
* decision, not the filtering itself.
*/
export function disabledSummaryTypes(
modules: Partial<Record<ModuleKey, boolean>>,
): Set<string> {
const dropped = new Set<string>();
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
}
return dropped;
}
/**
* iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric
* card) → the `MeasurementType` it is built from. The summary route emits
* cards by kind, but module membership is defined per measurement type in
* `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the
* summary payload gates off the SAME source of truth as the snapshot rather
* than a second, drift-prone kind→module list.
*
* Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood
* pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core
* vitals and always pass through — they are listed anyway so a reader can
* see the full emitted set and so a new card cannot be added without
* deciding which type backs it.
*/
export const SUMMARY_METRIC_TYPE_BY_KIND: Record<string, string> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
};
/**
* Drop the metric cards whose backing measurement type belongs to a module
* the account turned off. A card whose kind is absent from
* `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric
* or a new one, and silently hiding it would be worse than surfacing it.
*/
export function gateMetricCardsByModules<T extends { kind: string }>(
cards: ReadonlyArray<T>,
modules: Partial<Record<ModuleKey, boolean>>,
): T[] {
const dropped = disabledSummaryTypes(modules);
if (dropped.size === 0) return [...cards];
return cards.filter((card) => {
const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind];
return !type || !dropped.has(type);
});
}
@@ -0,0 +1,83 @@
/**
* `loadFhirContext` — doctorReport module backstop.
*
* The loader is the one door to the whole-record aggregate the FHIR face
* serves, including the decrypted insurance number. Every `/api/fhir/*` data
* route carries its own `requireModuleEnabled` gate (that is what produces the
* clean 403); this test pins the SECOND layer — the loader refuses outright
* rather than trusting that every present and future caller remembered.
*
* The gate resolver is exercised for real; only its data sources are stubbed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
import { loadFhirContext } from "@/lib/fhir/rest";
import { MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
function setDoctorReportModule(enabled: boolean): void {
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
select?: Record<string, boolean>;
}) => {
// The gate's own read and the loader's identity read hit the same model.
if (args?.select?.modulePreferencesJson) {
return {
gender: null,
disableCoach: false,
modulePreferencesJson: enabled ? {} : { doctorReport: false },
};
}
return { insuranceNumberEncrypted: "cipher", locale: "de" };
}) as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(collectDoctorReportData).mockResolvedValue({} as never);
});
describe("loadFhirContext — doctorReport backstop", () => {
it("refuses to assemble the record when the module is off", async () => {
setDoctorReportModule(false);
await expect(loadFhirContext("user-1")).rejects.toThrow(
/doctorReport.*disabled/,
);
// Nothing was aggregated and nothing was decrypted.
expect(collectDoctorReportData).not.toHaveBeenCalled();
});
it("assembles the record when the module is on", async () => {
setDoctorReportModule(true);
const ctx = await loadFhirContext("user-1");
expect(collectDoctorReportData).toHaveBeenCalledTimes(1);
expect(ctx.identity.insuranceNumber).toBe("A123456789");
});
});
+17
View File
@@ -22,6 +22,7 @@ import {
type DoctorReportData,
} from "@/lib/doctor-report-data";
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
import { isModuleEnabled } from "@/lib/modules/gate";
import type {
FhirBundleEntry,
FhirBundleLink,
@@ -182,12 +183,28 @@ export function operationOutcome(
* The window matches the document export's default (`normaliseDateRange`
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
* single row omits the identifier rather than 500-ing the read.
*
* MODULE BACKSTOP. Every `/api/fhir/*` data route calls
* `requireModuleEnabled(userId, "doctorReport")` itself, which is what
* produces the clean 403 envelope. This assertion is the second layer: the
* loader is the one door to the whole-record aggregate (including the
* decrypted insurance number), so it refuses outright rather than trusting
* that every present and future caller remembered the gate. It throws
* instead of returning an envelope because reaching it means a caller is
* missing its gate — a bug to surface, not a flow to serve.
*/
export async function loadFhirContext(userId: string): Promise<{
data: DoctorReportData;
identity: { insuranceNumber: string | null };
germanAtc: boolean;
}> {
if (!(await isModuleEnabled(userId, "doctorReport"))) {
throw new Error(
'loadFhirContext called with the "doctorReport" module disabled — ' +
"the calling route is missing its requireModuleEnabled gate",
);
}
const range = normaliseDateRange(undefined);
const [data, userRow] = await Promise.all([
collectDoctorReportData(userId, range, {}),