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),
},
});
});