From b59a78155b901376ae67fdc45ec331b2ca120312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:10:24 +0200 Subject: [PATCH] docs(api): publish the share link's PDF and FHIR downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both routes were exempt from the contract as `shareLink` — "authenticated by the URL credential, for a recipient with no account". That reason holds for the page a person opens. It does not hold for two routes whose whole purpose is to hand a practice a file it files into another system, which is exactly the audience a contract has. `/c/{token}/d/{id}` was published all along, one route away in the same tree. Each entry states the gate it shares with the page, the frozen selection it can never exceed, the flat 404 that covers every miss class including a documents-only link, and the 20/h per-link bucket. The FHIR entry says plainly that it is a download and not a REST face, and names the two resource families the bundle deliberately omits. The create-request description claimed "there is no FHIR or other machine-readable face behind a share token", which stopped being true when these routes landed. It now describes what a link actually serves and records that the insurance leaf is refused outright. The coverage guard gains the other half of its staleness check. It caught an exemption for a route that had disappeared, and never the reverse — an exemption left behind after the route was published, which is how these two would have stayed on the list while the contract already carried them. Break-proof: re-add either entry and it goes red naming it. --- docs/api/openapi.yaml | 94 ++++++++++++++++++- .../openapi-route-coverage-guard.test.ts | 33 ++++++- src/lib/openapi/routes/health-record.ts | 69 +++++++++++++- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a732bb79e..6cb8fd93b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -993,6 +993,91 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /c/{token}/report.pdf: + get: + tags: + - Export + summary: Download a share link's record as the clinical PDF (public) + description: "Anonymous, no-session download of exactly the record the page at `/c/{token}` renders — the same frozen + selection, resolved through the same `selectionFromStoredBlob`, so the file can never be wider than the page + above it. The raw `hls_` share token in the path is the ONLY credential; a passphrase-protected link + additionally requires the short-lived, token-scoped unlock cookie the browser already holds after unlocking. + Unknown / revoked / expired token, a locked gate, and a documents-only link (which carries no record at all) all + collapse to the same flat 404. Charts are embedded. Dates print in the OWNER's timezone and clock preference, + not the reader's, so a practice west of the record's own zone files a document dated the way the page it came + from was. The insurance number is never carried: the `INSURANCE` leaf is refused at share-link creation. + Rate-limited per link at 20/h — its own bucket, because generating a report is far more expensive than serving a + stored blob." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + responses: + "200": + description: "The report as `application/pdf`, `Content-Disposition: attachment`." + content: + application/pdf: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a + documents-only link with no record behind it). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link report-download rate limit exceeded (20/h). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /c/{token}/fhir: + get: + tags: + - Export + summary: Download a share link's record as an HL7 FHIR R4 Bundle (public) + description: "The machine-readable twin of `/c/{token}/report.pdf`: the same gate, the same frozen selection, the same + flat 404 for every miss class, the same 20/h per-link bucket. Returns an HL7 FHIR R4 **document** Bundle as a + download. This is a DOWNLOAD, not a FHIR REST face — a share token authenticates nothing beyond these three + routes and `/c/{token}/d/{id}`, and it never becomes a Bearer credential against `/api/fhir/*`. Allergies and + family history are deliberately absent from the bundle: they reach the owner's own export through a read this + surface does not perform, so the bundle carries the aggregated payload the page renders and nothing beside it. + No insurance number, for the same structural reason as the PDF." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + responses: + "200": + description: "FHIR R4 document Bundle (`application/fhir+json`), `Content-Disposition: attachment`." + content: + application/fhir+json: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a + documents-only link with no record behind it). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link report-download rate limit exceeded (20/h). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: @@ -20207,9 +20292,12 @@ components: description: "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope - rather than a default one. `documentIds` freezes the documents the link carries; `documentOnly` mints a share - that serves those documents and no record scope at all. A share link serves the rendered page and the documents - frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys + rather than a default one. The `INSURANCE` leaf is refused outright (422, naming it): the share view has never + decrypted the insurance number, and refusing it here makes that structural. `documentIds` freezes the documents + the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share + link serves the rendered page, the documents frozen onto it, and the same record as a PDF or FHIR Bundle + download under the token (`/c/{token}/report.pdf`, `/c/{token}/fhir`) — all behind the same gate and the same + frozen selection. A share token is not a Bearer credential and reaches no other route. Strict: unknown keys 422." EncryptedExportRequest: type: object diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 45114aa95..f1b015563 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -336,8 +336,14 @@ const UNPUBLISHED: Readonly> = { kind: "providerWebhook", methods: ["GET", "POST"], }, - "/c/{token}/fhir": { kind: "shareLink", methods: ["GET"] }, - "/c/{token}/report.pdf": { kind: "shareLink", methods: ["GET"] }, + // `/c/{token}/report.pdf` and `/c/{token}/fhir` were exempt here as + // `shareLink` and are published now. That reason holds for the page a + // recipient opens; it does not hold for two routes that serve machine + // formats a practice files into another system, which is precisely the + // audience a contract has. `/c/{token}/d/{id}` was published all along, one + // route away in the same tree, which is what makes the exemption read as an + // oversight rather than a decision. The `shareLink` reason stays: the next + // recipient-facing route is likelier to want it than not. "/.well-known/apple-app-site-association": { kind: "wellKnown", methods: ["GET"], @@ -462,6 +468,29 @@ describe("every route is published, unpublished, or retired", () => { ).toEqual([]); }); + it("the exemption list names routes that are not published after all", () => { + // The other way an entry goes stale, and the one this list had: a route + // gets published and its exemption stays behind, so the list reads as + // larger than it is and the reason attached to it looks load-bearing when + // nothing rests on it. Two share-link routes sat here in exactly that + // state, beside a published sibling in the same tree. + const table = openApiPaths as Record>; + const redundant: string[] = []; + + for (const [path, exempt] of Object.entries(UNPUBLISHED)) { + for (const method of exempt.methods) { + if (method.toLowerCase() in (table[path] ?? {})) { + redundant.push(`${method} ${path}`); + } + } + } + + expect( + redundant, + "these operations are published AND exempt, so the exemption states a reason nothing depends on", + ).toEqual([]); + }); + it("the contract does not publish a path that no route serves, unless it is retired", () => { const byPath = new Set(disk.map((route) => route.path)); const phantom = Object.keys(openApiPaths).filter( diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 995101bb0..0da6adf1f 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -36,7 +36,7 @@ import { const createShareLinkRequest = createShareLinkSchema.meta({ id: "CreateShareLinkRequest", description: - "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.", + "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. The `INSURANCE` leaf is refused outright (422, naming it): the share view has never decrypted the insurance number, and refusing it here makes that structural. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page, the documents frozen onto it, and the same record as a PDF or FHIR Bundle download under the token (`/c/{token}/report.pdf`, `/c/{token}/fhir`) — all behind the same gate and the same frozen selection. A share token is not a Bearer credential and reaches no other route. Strict: unknown keys 422.", }); const healthRecordExportRequest = exportSelectionSchema.meta({ @@ -281,6 +281,73 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/c/{token}/report.pdf": { + get: { + tags: ["Export"], + summary: "Download a share link's record as the clinical PDF (public)", + description: + "Anonymous, no-session download of exactly the record the page at `/c/{token}` renders — the same frozen selection, resolved through the same `selectionFromStoredBlob`, so the file can never be wider than the page above it. The raw `hls_` share token in the path is the ONLY credential; a passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie the browser already holds after unlocking. Unknown / revoked / expired token, a locked gate, and a documents-only link (which carries no record at all) all collapse to the same flat 404. Charts are embedded. Dates print in the OWNER's timezone and clock preference, not the reader's, so a practice west of the record's own zone files a document dated the way the page it came from was. The insurance number is never carried: the `INSURANCE` leaf is refused at share-link creation. Rate-limited per link at 20/h — its own bucket, because generating a report is far more expensive than serving a stored blob.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + }), + }, + responses: { + "200": { + description: + "The report as `application/pdf`, `Content-Disposition: attachment`.", + content: { + "application/pdf": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link report-download rate limit exceeded (20/h).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/c/{token}/fhir": { + get: { + tags: ["Export"], + summary: + "Download a share link's record as an HL7 FHIR R4 Bundle (public)", + description: + "The machine-readable twin of `/c/{token}/report.pdf`: the same gate, the same frozen selection, the same flat 404 for every miss class, the same 20/h per-link bucket. Returns an HL7 FHIR R4 **document** Bundle as a download. This is a DOWNLOAD, not a FHIR REST face — a share token authenticates nothing beyond these three routes and `/c/{token}/d/{id}`, and it never becomes a Bearer credential against `/api/fhir/*`. Allergies and family history are deliberately absent from the bundle: they reach the owner's own export through a read this surface does not perform, so the bundle carries the aggregated payload the page renders and nothing beside it. No insurance number, for the same structural reason as the PDF.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + }), + }, + responses: { + "200": { + description: + "FHIR R4 document Bundle (`application/fhir+json`), `Content-Disposition: attachment`.", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link report-download rate limit exceeded (20/h).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"],