From d4686ea2a615b8fca11d21c1de4e5bd9db9d5a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 02:20:48 +0200 Subject: [PATCH] feat(capabilities): advertise the FHIR REST face and share-link surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the $everything operation and honoured search params) to fhir/rest.ts; the metadata CapabilityStatement and the share-link resource-type enum now derive from it so the advertised set can never drift from what is routed. Extend GET /api/meta/capabilities with the FHIR REST descriptor (restBaseUrl, readScope, resourceTypes, operations, searchParams) and a clinician-share descriptor (supported, maxDays, resourceTypes, scopeable sections) — every list sourced from its canonical registry, guarded by the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to match. Fill the clinician test-matrix gap: assert the clinician view never requests a KVNR / identifier opt-in from the aggregator (default-OFF by construction) and resolves a rolling window without reaching data older than the frozen start. --- src/app/api/fhir/metadata/route.ts | 24 +++-- .../meta/capabilities/__tests__/route.test.ts | 49 +++++++++++ src/app/api/meta/capabilities/route.ts | 28 ++++++ .../__tests__/share-view-data.test.ts | 87 +++++++++++++++++++ src/lib/fhir/rest.ts | 24 +++++ src/lib/openapi/routes.ts | 34 +++++++- src/lib/validations/clinician-share-link.ts | 18 ++-- 7 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 src/lib/clinician-share/__tests__/share-view-data.test.ts diff --git a/src/app/api/fhir/metadata/route.ts b/src/app/api/fhir/metadata/route.ts index 29a0cab19..341a6c2fc 100644 --- a/src/app/api/fhir/metadata/route.ts +++ b/src/app/api/fhir/metadata/route.ts @@ -9,14 +9,12 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { FHIR_READ_SCOPE, fhirJsonResponse } from "@/lib/fhir/rest"; - -const READ_RESOURCES = [ - "Patient", - "Observation", - "MedicationStatement", - "MedicationAdministration", -] as const; +import { + FHIR_READ_SCOPE, + FHIR_REST_RESOURCE_TYPES, + FHIR_SEARCH_PARAMS, + fhirJsonResponse, +} from "@/lib/fhir/rest"; export const GET = apiHandler(async () => { await requireAuth(FHIR_READ_SCOPE); @@ -35,13 +33,13 @@ export const GET = apiHandler(async () => { documentation: "Read-only access to the authenticated user's own health record.", resource: [ - ...READ_RESOURCES.map((type) => ({ + ...FHIR_REST_RESOURCE_TYPES.map((type) => ({ type, interaction: [{ code: "read" }, { code: "search-type" }], - searchParam: [ - { name: "_count", type: "number" }, - { name: "_offset", type: "number" }, - ], + searchParam: FHIR_SEARCH_PARAMS.map((name) => ({ + name, + type: "number", + })), })), { type: "Patient", diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts index 65aae485e..b3c9871f9 100644 --- a/src/app/api/meta/capabilities/__tests__/route.test.ts +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -52,6 +52,17 @@ import { SNOMED_SYSTEM, GERMAN_ATC_DEFAULT_LOCALES, } from "@/lib/fhir/build-bundle"; +import { + FHIR_READ_SCOPE, + FHIR_REST_RESOURCE_TYPES, + FHIR_EVERYTHING_OPERATION, + FHIR_SEARCH_PARAMS, +} from "@/lib/fhir/rest"; +import { + SHARE_LINK_MAX_DAYS, + SHARE_LINK_RESOURCE_TYPES, +} from "@/lib/validations/clinician-share-link"; +import { exportSectionsSchema } from "@/lib/validations/health-record-export"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -75,6 +86,17 @@ type CapabilitiesBody = { atcSystem: string; snomedRoute: string; germanAtcDefaultLocales: string[]; + restBaseUrl: string; + readScope: string; + resourceTypes: string[]; + operations: string[]; + searchParams: string[]; + }; + share: { + supported: boolean; + maxDays: number; + resourceTypes: string[]; + sections: string[]; }; }; }; @@ -139,6 +161,33 @@ describe("GET /api/meta/capabilities — drift guards", () => { ]); }); + it("fhir REST descriptor mirrors the canonical rest.ts constants", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.fhir.restBaseUrl).toBe("/api/fhir"); + expect(body.data.fhir.readScope).toBe(FHIR_READ_SCOPE); + expect(body.data.fhir.resourceTypes).toEqual([ + ...FHIR_REST_RESOURCE_TYPES, + ]); + expect(body.data.fhir.operations).toEqual([FHIR_EVERYTHING_OPERATION]); + expect(body.data.fhir.searchParams).toEqual([...FHIR_SEARCH_PARAMS]); + }); + + it("share descriptor mirrors the canonical share-link + section sources", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.share.supported).toBe(true); + expect(body.data.share.maxDays).toBe(SHARE_LINK_MAX_DAYS); + // The shareable resource types are exactly the REST catalogue, by + // construction — the share can never scope to an unrouted type. + expect(body.data.share.resourceTypes).toEqual([ + ...SHARE_LINK_RESOURCE_TYPES, + ]); + expect([...body.data.share.sections].sort()).toEqual( + Object.keys(exportSectionsSchema.shape).sort(), + ); + }); + it("quantityTypes are sourced from the HealthKit ingest mapping", async () => { const res = await call(); const body = (await res.json()) as CapabilitiesBody; diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index eaeeea996..4fed7ca49 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -40,6 +40,17 @@ import { SNOMED_SYSTEM, GERMAN_ATC_DEFAULT_LOCALES, } from "@/lib/fhir/build-bundle"; +import { + FHIR_READ_SCOPE, + FHIR_REST_RESOURCE_TYPES, + FHIR_EVERYTHING_OPERATION, + FHIR_SEARCH_PARAMS, +} from "@/lib/fhir/rest"; +import { + SHARE_LINK_MAX_DAYS, + SHARE_LINK_RESOURCE_TYPES, +} from "@/lib/validations/clinician-share-link"; +import { exportSectionsSchema } from "@/lib/validations/health-record-export"; export const dynamic = "force-dynamic"; @@ -96,6 +107,23 @@ export const GET = apiHandler(async () => { atcSystem: ATC_SYSTEM, snomedRoute: SNOMED_SYSTEM, germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES, + // Read-only REST face (v1.11): the resource types served, the + // whole-record operation, the honoured search params and the narrow + // Bearer scope. All sourced from the canonical `rest.ts` constants. + restBaseUrl: "/api/fhir", + readScope: FHIR_READ_SCOPE, + resourceTypes: FHIR_REST_RESOURCE_TYPES, + operations: [FHIR_EVERYTHING_OPERATION], + searchParams: FHIR_SEARCH_PARAMS, + }, + // Clinician share-link surface (v1.11): a scoped, time-boxed, revocable + // read-only link to the owner's record. Descriptor sourced from the + // canonical share validation + export-section constants. + share: { + supported: true, + maxDays: SHARE_LINK_MAX_DAYS, + resourceTypes: SHARE_LINK_RESOURCE_TYPES, + sections: Object.keys(exportSectionsSchema.shape), }, }); }); diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts new file mode 100644 index 000000000..b69c60843 --- /dev/null +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -0,0 +1,87 @@ +/** + * v1.11.0 (Epic C, C8) — clinician-view scoped data load guarantees. + * + * The public clinician view aggregates ONLY the data the owner froze into the + * link, and it must NEVER surface the insurance number (KVNR). KVNR is + * default-OFF by construction: `loadShareViewData` calls the doctor-report + * aggregator with the frozen window + section toggles and nothing else — no + * identifier opt-in, no decrypt path. This test pins that contract so a future + * change can't silently widen the clinician view to leak the KVNR. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), +})); +vi.mock("@/lib/validations/doctor-report-prefs", () => ({ + parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}), +})); + +import { loadShareViewData } from "../share-view-data"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import type { ShareContext } from "../resolve-share-token"; + +const collect = collectDoctorReportData as ReturnType; + +function ctx(overrides: Partial = {}): ShareContext { + return { + shareLinkId: "link-1", + ownerUserId: "owner-1", + label: "Clinic", + rangeStart: new Date("2026-01-01T00:00:00Z"), + rangeEnd: new Date("2026-02-01T00:00:00Z"), + sectionsJson: { mood: false }, + resourceTypes: [], + allowFhirApi: false, + expiresAt: new Date(Date.now() + 86_400_000), + ...overrides, + } as ShareContext; +} + +describe("loadShareViewData — KVNR default OFF", () => { + beforeEach(() => { + vi.clearAllMocks(); + collect.mockResolvedValue({ + patient: { displayName: "Shared record" }, + }); + }); + + it("scopes the aggregator to the OWNER from the share context, never the wire", async () => { + await loadShareViewData(ctx()); + expect(collect).toHaveBeenCalledTimes(1); + expect(collect.mock.calls[0]![0]).toBe("owner-1"); + }); + + it("never requests an identifier / KVNR opt-in from the aggregator", async () => { + await loadShareViewData(ctx()); + const opts = collect.mock.calls[0]![2] as Record; + // The only option ever passed is the frozen section toggles — no + // includeIdentifiers, no kvnr, no decrypt flag. Default-OFF by absence. + expect(opts).toBeDefined(); + expect(opts).not.toHaveProperty("includeIdentifiers"); + expect(opts).not.toHaveProperty("kvnr"); + expect(opts).not.toHaveProperty("insuranceNumber"); + expect(Object.keys(opts)).toEqual(["sections"]); + }); + + it("returns a report payload carrying no insurance number", async () => { + const { report } = await loadShareViewData(ctx()); + const patient = (report as { patient?: Record }).patient; + expect(patient).not.toHaveProperty("insuranceNumber"); + expect(patient).not.toHaveProperty("kvnr"); + }); + + it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => { + const now = Date.now(); + await loadShareViewData(ctx({ rangeEnd: null })); + const range = collect.mock.calls[0]![1] as { + start: Date; + end: Date; + days: number; + }; + expect(range.start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + // Rolling end materialises near "now", never before the frozen start. + expect(range.end.getTime()).toBeGreaterThanOrEqual(now - 5_000); + expect(range.days).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index f6b8e28ac..a72d345a3 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -33,6 +33,30 @@ import type { /** The Bearer scope a narrow-scoped token must carry to read the FHIR face. */ export const FHIR_READ_SCOPE = "fhir:read"; +/** + * Canonical catalogue of FHIR R4 resource types the read-only REST face serves + * (`read` + `search-type` interactions only — no write, ever). One source of + * truth: the `metadata` CapabilityStatement, the `/api/meta/capabilities` + * discovery surface, and the share-link resource-type enum all derive from + * this so the advertised set can never drift from what is actually routed. + */ +export const FHIR_REST_RESOURCE_TYPES = [ + "Patient", + "Observation", + "MedicationStatement", + "MedicationAdministration", +] as const; + +/** + * The whole-record operation the REST face exposes (`GET /api/fhir/$everything`), + * returning the existing `type: "document"` Bundle. Surfaced in discovery so a + * client knows the snapshot pull exists alongside the per-resource searches. + */ +export const FHIR_EVERYTHING_OPERATION = "$everything"; + +/** Search parameters honoured uniformly across the search routes. */ +export const FHIR_SEARCH_PARAMS = ["_count", "_offset"] as const; + /** Canonical FHIR media type for every response (success or outcome). */ export const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8"; diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 7b6d70e2b..5d35f7df2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1952,8 +1952,40 @@ const capabilitiesResponse = z germanAtcDefaultLocales: z .array(z.string()) .describe("App locales that default the additive BfArM ATC coding on."), + restBaseUrl: z + .string() + .describe("Base path of the read-only FHIR R4 REST face."), + readScope: z + .string() + .describe("Bearer scope a narrow token needs to read the FHIR face."), + resourceTypes: z + .array(z.string()) + .describe("FHIR resource types the REST face serves (read + search)."), + operations: z + .array(z.string()) + .describe("Whole-record operations exposed (e.g. $everything)."), + searchParams: z + .array(z.string()) + .describe("Search parameters honoured uniformly across the search routes."), }) - .describe("FHIR coding constants the health-record export emits."), + .describe( + "FHIR coding constants + the read-only REST face descriptor (v1.11).", + ), + share: z + .object({ + supported: z.boolean().describe("Whether clinician share links are served."), + maxDays: z + .number() + .int() + .describe("Maximum lifetime of a share link, in days. No never-expiring share."), + resourceTypes: z + .array(z.string()) + .describe("FHIR resource types a share link may be scoped to serve."), + sections: z + .array(z.string()) + .describe("Scopeable report sections a share link may toggle."), + }) + .describe("Clinician share-link surface descriptor (v1.11)."), }) .meta({ id: "CapabilitiesResponse", diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index 3878fa83a..34c58fc00 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -13,23 +13,21 @@ */ import { z } from "zod/v4"; +import { FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest"; import { exportSectionsSchema } from "@/lib/validations/health-record-export"; /** Maximum lifetime of a share link, in days. No never-expiring share. */ export const SHARE_LINK_MAX_DAYS = 90; /** - * The FHIR resource types a share link may be scoped to serve — the read-only - * catalogue the REST face exposes. A create request may select any subset; - * an empty array means "no FHIR resources" (view-only, when `allowFhirApi` is - * off this is moot). + * The FHIR resource types a share link may be scoped to serve — exactly the + * read-only catalogue the REST face exposes. Derived from the single canonical + * `FHIR_REST_RESOURCE_TYPES` so a share can never be scoped to a type the REST + * face does not actually route. A create request may select any subset; an + * empty array means "no FHIR resources" (view-only, moot when `allowFhirApi` + * is off). */ -export const SHARE_LINK_RESOURCE_TYPES = [ - "Patient", - "Observation", - "MedicationStatement", - "MedicationAdministration", -] as const; +export const SHARE_LINK_RESOURCE_TYPES = FHIR_REST_RESOURCE_TYPES; export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);