feat(capabilities): advertise the FHIR REST face and share-link surface

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 02:34:33 +02:00
1 parent 93694cacd0
commit d4686ea2a6
7 files changed
+240 -24

No files matched your search

+11 -13
View File
@@ -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",
@@ -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;
+28
View File
@@ -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),
},
});
});
@@ -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<typeof vi.fn>;
function ctx(overrides: Partial<ShareContext> = {}): 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<string, unknown>;
// 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<string, unknown> }).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);
});
});
+24
View File
@@ -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";
+33 -1
View File
@@ -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",
+8 -10
View File
@@ -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);