From 4548df84bfafb77a57ab150ba461cb3583dcb1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:39:58 +0200 Subject: [PATCH] Gate the FHIR REST face on the doctorReport module /api/fhir/* serves the same whole-record aggregate as /api/export/health-record -- right down to the decrypted insurance number on the Patient resource -- but never consulted the module gate. The export refuses with a 403 module.disabled envelope when the account turns the doctor-report module off; the FHIR face happily returned the full Bundle to the same token. Gate all five data routes (Patient, Observation, MedicationStatement, MedicationAdministration, $everything). Refuse rather than omit: a whole-record export has no truthful partial answer, and unlike the sync delta feed no background client depends on it draining to stay consistent. The refusal reuses the shared module.disabled envelope so the errorCode clients already branch on stays the same. loadFhirContext gains a fail-closed assertion as a second layer. It is the one door to the aggregate, so it refuses outright instead of trusting that every future caller remembers its gate; reaching it means a route is missing one, which is a bug to surface rather than a flow to serve. The static CapabilityStatement at /api/fhir/metadata stays open -- server metadata, no user data. --- src/app/api/fhir/$everything/route.ts | 16 ++ .../fhir/MedicationAdministration/route.ts | 16 ++ src/app/api/fhir/MedicationStatement/route.ts | 16 ++ src/app/api/fhir/Observation/route.ts | 16 ++ src/app/api/fhir/Patient/route.ts | 16 ++ .../api/fhir/__tests__/module-gate.test.ts | 179 ++++++++++++++++++ src/app/api/fhir/__tests__/route.test.ts | 9 + .../__tests__/rest-module-backstop.test.ts | 83 ++++++++ src/lib/fhir/rest.ts | 17 ++ 9 files changed, 368 insertions(+) create mode 100644 src/app/api/fhir/__tests__/module-gate.test.ts create mode 100644 src/lib/fhir/__tests__/rest-module-backstop.test.ts diff --git a/src/app/api/fhir/$everything/route.ts b/src/app/api/fhir/$everything/route.ts index e804b1f0e..e42a49b80 100644 --- a/src/app/api/fhir/$everything/route.ts +++ b/src/app/api/fhir/$everything/route.ts @@ -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"); diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index a8e489309..747b8f71b 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -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"); diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index 6cbafb86b..b7565735d 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -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"); diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index c440b2577..22760e71b 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -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"); diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index 19d6e1aa8..c6e2f3b74 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -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"); diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts new file mode 100644 index 000000000..f869a3e75 --- /dev/null +++ b/src/app/api/fhir/__tests__/module-gate.test.ts @@ -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(); + 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] +> = [ + ["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"); + }); +}); diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index 78b000b4e..0a061e8da 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -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(); 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 }, diff --git a/src/lib/fhir/__tests__/rest-module-backstop.test.ts b/src/lib/fhir/__tests__/rest-module-backstop.test.ts new file mode 100644 index 000000000..e048ee6c5 --- /dev/null +++ b/src/lib/fhir/__tests__/rest-module-backstop.test.ts @@ -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; + }) => { + // 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"); + }); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index a72d345a3..9094504ed 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -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, {}),