feat(api): add GET /api/meta/capabilities discovery endpoint

Expose the server's live id vocabularies (derived-metric ids,
vitals-baseline types, insights layout tile-ids, metric-status ids,
the HealthKit ingest mapping, computed-score types, write allowlist) and
the FHIR coding constants, plus the running contract version, behind
requireAuth (cookie or Bearer, not admin).

Every list is sourced from its canonical server constant / registry, so
the endpoint cannot drift from what the routes actually accept and emit —
retiring the doc-vs-server enum-drift class for the native client. Export
the FHIR ATC/SNOMED URIs and a shared german-ATC default-locale set so the
export route and the discovery surface share one source.

Register the route in the OpenAPI table; drift-guard tests pin
derivedMetricIds to the registry and writeAllowlist to the constant.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 08:56:19 +02:00
1 parent 5ae7aa7570
commit 192fe45469
6 files changed
+379 -4

No files matched your search

+7 -2
View File
@@ -39,7 +39,10 @@ import {
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
buildFhirDocumentBundle,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
exportSelectionSchema,
toDoctorReportPrefs,
@@ -99,7 +102,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
// German-region locale. The WHO ATC coding is unaffected either way.
const germanAtc = selection.germanAtc ?? locale === "de";
const germanAtc =
selection.germanAtc ??
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
const [data, userTz, userRow] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
@@ -0,0 +1,172 @@
/**
* GET /api/meta/capabilities route tests.
*
* This endpoint exists to retire the "doc says N, server ships M" enum-drift
* class for the native client — so the drift-guard assertions ARE the point:
*
* 1. 401 when unauthenticated.
* 2. `derivedMetricIds` is exactly the derived registry's id set (same
* length AND members) — the endpoint can never silently diverge from
* the registry it claims to mirror.
* 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES`
* constant — a client reads the live write allowlist, not a copy.
* 4. Every quantity-mapping row + the FHIR constants + the contract version
* are sourced from the canonical server constants, not hand-typed here.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// The route transitively imports `@/lib/insights/derived/wellness-scores`,
// which imports the Prisma client; mock it so the module graph loads without
// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain
// object), never the client.
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping";
import {
ATC_SYSTEM,
SNOMED_SYSTEM,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
type CapabilitiesBody = {
data: {
apiContractVersion: string;
derivedMetricIds: string[];
vitalsBaselineTypes: string[];
layoutTileIds: string[];
metricStatusIds: string[];
ingest: {
quantityTypes: { type: string; hk: string; unit: string }[];
eventTypes: string[];
computedScores: string[];
writeAllowlist: string[];
};
fhir: {
atcSystem: string;
snomedRoute: string;
germanAtcDefaultLocales: string[];
};
};
};
async function call(): Promise<Response> {
return (GET as unknown as () => Promise<Response>)();
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/meta/capabilities — auth", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await call();
expect(res.status).toBe(401);
});
});
describe("GET /api/meta/capabilities — drift guards", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("derivedMetricIds is exactly the derived registry's id set", async () => {
const res = await call();
expect(res.status).toBe(200);
const body = (await res.json()) as CapabilitiesBody;
// Length AND membership — the endpoint cannot silently drift from the
// registry (the whole reason it exists).
expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length);
expect([...body.data.derivedMetricIds].sort()).toEqual(
[...DERIVED_METRIC_IDS].sort(),
);
});
it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.ingest.writeAllowlist).toEqual([
...WRITABLE_MEASUREMENT_SOURCES,
]);
});
it("computedScores are the three persisted wellness scores", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect([...body.data.ingest.computedScores].sort()).toEqual(
["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"],
);
});
it("fhir constants mirror the FHIR builder source", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM);
expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM);
expect(body.data.fhir.germanAtcDefaultLocales).toEqual([
...GERMAN_ATC_DEFAULT_LOCALES,
]);
});
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
// Every emitted quantity row must trace back to a real mapping entry —
// never an entry the ingest path doesn't actually accept.
const byHk = new Map(
Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]),
);
expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0);
for (const q of body.data.ingest.quantityTypes) {
const mapping = byHk.get(q.hk);
expect(mapping).toBeDefined();
expect(q.type).toBe(mapping!.measurementType);
expect(q.unit).toBe(mapping!.dbUnit);
// Quantity rows are the non-event-class mappings.
expect(
Boolean(
mapping!.eventClassificationMap || mapping!.fallbackClassification,
),
).toBe(false);
}
});
it("apiContractVersion is a non-empty string", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(typeof body.data.apiContractVersion).toBe("string");
expect(body.data.apiContractVersion.length).toBeGreaterThan(0);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* GET /api/meta/capabilities
*
* Live capability / discovery surface for the native client. Returns the
* server's REAL id vocabularies — derived-metric ids, vitals-baseline
* types, insights layout tile-ids, metric-status ids, the HealthKit ingest
* mapping, and the FHIR coding constants — plus the running API contract
* version. The iOS app reads this once on launch (or on a version bump) and
* gates its UI / decoder against what the server actually ships, retiring
* the recurring "doc says N, server ships M" enum-drift class.
*
* Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every
* authenticated client may discover the contract.
*
* DESIGN RULE: every list is SOURCED from the existing canonical server
* constant / Zod enum / registry — never hand-duplicated here. Adding a
* derived metric, a tile, a writable source, or a HealthKit mapping
* auto-updates this endpoint, so it cannot drift from the real registries.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import packageJson from "../../../../../package.json";
import {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
} from "@/lib/insights/derived/registry";
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores";
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
import {
APPLE_HEALTH_TYPE_MAP,
type AppleHealthMapping,
} from "@/lib/measurements/apple-health-mapping";
import {
ATC_SYSTEM,
SNOMED_SYSTEM,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
export const dynamic = "force-dynamic";
/**
* An EVENT-class HealthKit identifier carries a device-produced verdict, not
* a continuous reading — it is marked by an `eventClassificationMap` or a
* `fallbackClassification` in the ingest mapping. Everything else is a
* quantity sample. Splitting the single mapping by this predicate keeps the
* two ingest lists derived from one registry.
*/
function isEventMapping(m: AppleHealthMapping): boolean {
return Boolean(m.eventClassificationMap || m.fallbackClassification);
}
export const GET = apiHandler(async () => {
await requireAuth();
annotate({ action: { name: "meta.capabilities.read" } });
// Mirror the /api/version source so the contract version cannot drift from
// the running build's reported version (build-arg env wins over the
// package.json fallback for local dev).
const apiContractVersion =
process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
const mappings = Object.values(APPLE_HEALTH_TYPE_MAP);
const quantityTypes = mappings
.filter((m) => !isEventMapping(m))
.map((m) => ({
type: m.measurementType,
hk: m.hkIdentifier,
unit: m.dbUnit,
}));
// Distinct event-class MeasurementTypes (several HK event identifiers can
// share one MeasurementType, e.g. the audio-exposure pair).
const eventTypes = Array.from(
new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)),
);
return apiSuccess({
apiContractVersion,
derivedMetricIds: DERIVED_METRIC_IDS,
vitalsBaselineTypes: VITALS_BASELINE_TYPES,
layoutTileIds: INSIGHTS_TILE_IDS,
metricStatusIds: METRIC_STATUS_IDS,
ingest: {
quantityTypes,
eventTypes,
computedScores: Object.values(WELLNESS_SCORE_TYPES),
writeAllowlist: WRITABLE_MEASUREMENT_SOURCES,
},
fhir: {
atcSystem: ATC_SYSTEM,
snomedRoute: SNOMED_SYSTEM,
germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES,
},
});
});
+11 -2
View File
@@ -68,7 +68,7 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
* coding. Both are additive `coding[]` entries on the same concept; the
* free-text `.text` (the user's medication name) stays the anchor.
*/
const ATC_SYSTEM = "http://www.whocc.no/atc";
export const ATC_SYSTEM = "http://www.whocc.no/atc";
/**
* German national ATC URI maintained by the BfArM. Same ATC classification
* as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
@@ -78,7 +78,16 @@ const ATC_SYSTEM = "http://www.whocc.no/atc";
const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
const SNOMED_SYSTEM = "http://snomed.info/sct";
export const SNOMED_SYSTEM = "http://snomed.info/sct";
/**
* The app locales for which a health-record export defaults `germanAtc` on
* (the additive BfArM ATC coding). The export route derives the flag from
* the user's locale against this set; the capabilities endpoint surfaces it
* verbatim so a client can predict the coding without a round-trip. Keeping
* it here — beside the BfArM URI it gates — makes the two move together.
*/
export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
/** Options threaded from the export route into the builder. Additive, all defaulted. */
export interface FhirBuildOptions {
+1
View File
@@ -68,6 +68,7 @@ const openApiBase: Pick<
{ name: "Export" },
{ name: "Sync" },
{ name: "Admin" },
{ name: "Meta" },
],
};
+87
View File
@@ -1854,6 +1854,71 @@ exportSelectionSchema.meta({
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
});
// v1.10.2 — live capability / discovery response. Every list is sourced
// server-side from the canonical registry it documents, so the wire shape
// here is the contract; the runtime values are authoritative and never
// hand-duplicated. Used by the native client to gate its UI / decoder
// against what the server actually ships (retires the doc-vs-server
// enum-drift class).
const capabilitiesResponse = z
.object({
apiContractVersion: z
.string()
.describe("Running build version — mirrors GET /api/version `version`."),
derivedMetricIds: z
.array(z.string())
.describe("Closed derived-metric id set (GET /api/insights/derived)."),
vitalsBaselineTypes: z
.array(z.string())
.describe("MeasurementTypes the typical-range baseline engine supports."),
layoutTileIds: z
.array(z.string())
.describe("Canonical insights layout tile-id set (English ids)."),
metricStatusIds: z
.array(z.string())
.describe("Closed metric-status / assessment id set."),
ingest: z
.object({
quantityTypes: z
.array(
z.object({
type: z.string().describe("HealthLog MeasurementType."),
hk: z.string().describe("HealthKit identifier."),
unit: z.string().describe("Canonical DB unit."),
}),
)
.describe("Accepted HealthKit quantity-sample mappings."),
eventTypes: z
.array(z.string())
.describe(
"MeasurementTypes for device-flagged EVENT-class HealthKit samples.",
),
computedScores: z
.array(z.string())
.describe("Server-owned nightly composite score types."),
writeAllowlist: z
.array(z.string())
.describe(
"MeasurementSources a client may attribute on a write (others are server-owned).",
),
})
.describe("Ingest vocabularies the batch / single-write paths accept."),
fhir: z
.object({
atcSystem: z.string().describe("WHO ATC CodeSystem URI."),
snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."),
germanAtcDefaultLocales: z
.array(z.string())
.describe("App locales that default the additive BfArM ATC coding on."),
})
.describe("FHIR coding constants the health-record export emits."),
})
.meta({
id: "CapabilitiesResponse",
description:
"Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.",
});
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
const stdResponses = {
@@ -1874,6 +1939,28 @@ const stdResponses = {
// ── Path table ───────────────────────────────────────────────────────
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/meta/capabilities": {
get: {
tags: ["Meta"],
summary: "Live server capability / id-vocabulary discovery",
description:
"Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).",
responses: {
"200": {
description: "Capability snapshot.",
content: {
"application/json": {
schema: dataEnvelope(
capabilitiesResponse,
"CapabilitiesEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/export/health-record": {
post: {
tags: ["Export"],