From c9c824ca4d516ec4ddc01a1231c41769cf623b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 19:53:46 +0200 Subject: [PATCH] Publish share-selection group membership in capabilities GET /api/meta/capabilities dropped group membership at the wire: it shipped a flat group-id list and a flat leaf list with no boundary between them, so a native client could not tell which leaves live under which group, or which group is the fenced sensitive tier. That made it unsafe for the client to offer a "select all" affordance for any group, since it could not mirror the server's fence against bundling more than one sensitive leaf behind a single control. Add SHARE_GROUPS to the report-selection catalogue, derived from the existing REPORT_GROUPS/MEASUREMENT_LEAF_GROUP/STRUCTURED_LEAF_GROUP maps: each group now carries its member leaf ids, and the group whose id equals SENSITIVE_GROUP_ID carries sensitive: true. share.groups in the capabilities response now ships this shape instead of bare ids; share.leaves is unchanged. Update the OpenAPI schema and regenerate docs/api/openapi.yaml. Add an invariant test asserting every leaf in the flat leaves set appears in exactly one group and no group carries a leaf outside that set, plus a check that exactly one group is flagged sensitive. --- docs/api/openapi.yaml | 20 ++++++++- .../meta/capabilities/__tests__/route.test.ts | 30 ++++++++++++- src/app/api/meta/capabilities/route.ts | 12 ++--- src/lib/openapi/routes/meta.ts | 21 ++++++++- .../__tests__/catalogue-guard.test.ts | 45 +++++++++++++++++++ src/lib/report-selection/catalogue.ts | 22 +++++++++ 6 files changed, 139 insertions(+), 11 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6c5d33755..f976e4b34 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16096,8 +16096,24 @@ components: groups: type: array items: - type: string - description: Selection group ids, in presentation order. + type: object + properties: + id: + type: string + description: Group id. + leaves: + type: array + items: + type: string + description: Member leaf ids, in the order the panel renders them. + sensitive: + description: "Present and true only on the fenced tier: no single control may switch on more than one of its leaves." + type: boolean + required: + - id + - leaves + additionalProperties: false + description: Selection groups, in presentation order, each carrying its member leaf ids. leaves: type: array items: diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts index e4102b843..1a068b6d0 100644 --- a/src/app/api/meta/capabilities/__tests__/route.test.ts +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -61,6 +61,7 @@ import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; import { ALL_LEAF_IDS, REPORT_GROUP_ORDER, + SENSITIVE_GROUP_ID, } from "@/lib/report-selection/catalogue"; const SESSION_OK = { @@ -96,7 +97,7 @@ type CapabilitiesBody = { maxDays: number; reportDownload: string[]; selectionVersion: number; - groups: string[]; + groups: { id: string; leaves: string[]; sensitive?: boolean }[]; leaves: string[]; }; }; @@ -187,12 +188,37 @@ describe("GET /api/meta/capabilities — drift guards", () => { // that was never built. expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]); expect(body.data.share.selectionVersion).toBe(2); - expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]); + expect(body.data.share.groups.map((g) => g.id)).toEqual([ + ...REPORT_GROUP_ORDER, + ]); expect([...body.data.share.leaves].sort()).toEqual( [...ALL_LEAF_IDS].sort(), ); }); + it("share.groups carries membership: every leaf in exactly one group, fence flagged once", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + + const seen = new Set(); + for (const group of body.data.share.groups) { + for (const leaf of group.leaves) { + expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe( + false, + ); + seen.add(leaf); + } + } + // Every leaf in the flat set is in exactly one group, and no group holds + // a leaf absent from the flat set — the invariant that makes a native + // client's fence trustworthy. + expect([...seen].sort()).toEqual([...body.data.share.leaves].sort()); + + const sensitiveGroups = body.data.share.groups.filter((g) => g.sensitive); + expect(sensitiveGroups).toHaveLength(1); + expect(sensitiveGroups[0]?.id).toBe(SENSITIVE_GROUP_ID); + }); + it("quantityTypes are sourced from the HealthKit ingest mapping", async () => { const res = await call(); const body = (await res.json()) as CapabilitiesBody; diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index b9ce59410..64d99ad44 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -46,10 +46,7 @@ import { FHIR_SEARCH_PARAMS, } from "@/lib/fhir/rest"; import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; -import { - ALL_LEAF_IDS, - REPORT_GROUP_ORDER, -} from "@/lib/report-selection/catalogue"; +import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue"; export const dynamic = "force-dynamic"; @@ -135,7 +132,12 @@ export const GET = apiHandler(async () => { maxDays: SHARE_LINK_MAX_DAYS, reportDownload: ["fhir", "pdf"], selectionVersion: 2, - groups: REPORT_GROUP_ORDER, + // Each group carries its own member leaves, and the fenced tier carries + // `sensitive: true` — the membership the panel, the PDF and the guard + // tests already enforce, published so the native client can mirror the + // fence instead of guessing which leaves it may bundle behind one + // control. `leaves` stays the flat total set for back-compat. + groups: SHARE_GROUPS, leaves: ALL_LEAF_IDS, }, }); diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 8b9249711..f8cd49c40 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -113,8 +113,25 @@ const capabilitiesResponse = z .int() .describe("Version of the leaf-selection grammar this build speaks."), groups: z - .array(z.string()) - .describe("Selection group ids, in presentation order."), + .array( + z.object({ + id: z.string().describe("Group id."), + leaves: z + .array(z.string()) + .describe( + "Member leaf ids, in the order the panel renders them.", + ), + sensitive: z + .boolean() + .optional() + .describe( + "Present and true only on the fenced tier: no single control may switch on more than one of its leaves.", + ), + }), + ) + .describe( + "Selection groups, in presentation order, each carrying its member leaf ids.", + ), leaves: z .array(z.string()) .describe( diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 30e1eb09e..0fb75962a 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -25,7 +25,9 @@ import { REPORT_GROUPS, REPORT_GROUP_LABEL_KEYS, REPORT_GROUP_ORDER, + SENSITIVE_GROUP_ID, SENSITIVE_LEAF_IDS, + SHARE_GROUPS, STRUCTURED_LEAF_IDS, leafLabelKey, } from "../catalogue"; @@ -80,6 +82,49 @@ describe("report selection catalogue", () => { }); }); +/** + * `SHARE_GROUPS` is what `GET /api/meta/capabilities` publishes as + * `share.groups` — the membership a native client needs to mirror the + * sensitive-tier fence itself. It is derived from `REPORT_GROUPS`, so these + * checks are the invariant that makes that fence trustworthy: every leaf in + * the flat `ALL_LEAF_IDS` set lands in exactly one group, no group carries a + * leaf the flat set doesn't have, and exactly one group — the one whose id is + * `SENSITIVE_GROUP_ID` — carries `sensitive: true`. + * + * Mutation check (verified red, then reverted): push a leaf into two groups + * in `SHARE_GROUPS` by hand → the "exactly one group" assertion goes red + * naming the duplicated leaf. + */ +describe("share-group membership (GET /api/meta/capabilities share.groups)", () => { + it("covers exactly the flat leaf set, no leaf in more than one group", () => { + const seen = new Set(); + for (const group of SHARE_GROUPS) { + for (const leaf of group.leaves) { + expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe( + false, + ); + seen.add(leaf); + } + } + expect([...seen].sort()).toEqual([...ALL_LEAF_IDS].sort()); + }); + + it("flags sensitive: true on exactly the fenced group", () => { + const flagged = SHARE_GROUPS.filter((g) => g.sensitive); + expect(flagged).toHaveLength(1); + expect(flagged[0]?.id).toBe(SENSITIVE_GROUP_ID); + }); + + it("orders groups and leaves identically to REPORT_GROUPS", () => { + expect(SHARE_GROUPS.map((g) => g.id)).toEqual( + REPORT_GROUPS.map((g) => g.id), + ); + for (const [i, group] of SHARE_GROUPS.entries()) { + expect(group.leaves).toEqual(REPORT_GROUPS[i]!.leaves); + } + }); +}); + describe("template purity", () => { it("keeps the shipped template clear of the fenced tier", () => { const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) => diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index 973827031..f8e1b86da 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -344,6 +344,28 @@ export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap( (group) => group.leaves, ); +/** + * The wire shape for `GET /api/meta/capabilities` `share.groups`: each group + * carries its own member leaves, and the fenced tier carries `sensitive: + * true`, derived from `SENSITIVE_GROUP_ID` rather than a second string + * literal. A native client that cannot see group membership cannot mirror the + * fence (no single control switching on more than one sensitive leaf), so + * this — not `REPORT_GROUP_ORDER` alone — is what the endpoint should ship. + * + * Order matches `REPORT_GROUPS` exactly: group order is `REPORT_GROUP_ORDER`, + * leaf order within a group is structured-then-measurement, so this shape and + * the flat `ALL_LEAF_IDS` list agree leaf-for-leaf. + */ +export const SHARE_GROUPS: ReadonlyArray<{ + id: ReportGroupId; + leaves: readonly ReportLeafId[]; + sensitive?: true; +}> = REPORT_GROUPS.map((group) => ({ + id: group.id, + leaves: group.leaves, + ...(group.id === SENSITIVE_GROUP_ID ? { sensitive: true as const } : {}), +})); + const ALL_LEAF_SET = new Set(ALL_LEAF_IDS); /** Whether a raw string is a leaf id this build knows. */