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

@@ -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);