mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
93694cacd0
commit
d4686ea2a6
7 files changed
+240
-24
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user