diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 456cf79dc..18fbe4cd8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -989,7 +989,9 @@ paths: - Export summary: Create a clinician share link (v1.11.0) description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY - ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen + ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with + `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase + cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422." requestBody: @@ -1148,6 +1150,50 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/c/{token}/unlock: + post: + tags: + - Export + summary: Verify a share-link passphrase (v1.18.7, public) + description: "Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted + passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, + httpOnly cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every + failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401." + parameters: + - in: path + name: token + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + passphrase: + type: string + minLength: 1 + maxLength: 64 + required: + - passphrase + additionalProperties: false + responses: + "200": + description: Passphrase accepted; short-lived token-scoped unlock cookie set. + content: + application/json: + schema: + $ref: "#/components/schemas/ShareLinkUnlock" + "401": + description: Invalid passphrase (blunt — no failure detail leaked). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/fhir/metadata: get: tags: @@ -9701,6 +9747,8 @@ components: type: string allowFhirApi: type: boolean + protected: + type: boolean expiresAt: type: string createdAt: @@ -9720,6 +9768,16 @@ components: token: type: string description: Raw `hls_` token — returned ONCE and unrecoverable thereafter. + passphrase: + type: string + description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable. + shareUrl: + type: string + description: Absolute `/c/` URL (no passphrase). + qrUrl: + type: string + description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR + payload; the fragment is never sent to the server. required: - id - label @@ -9727,6 +9785,7 @@ components: - rangeEnd - resourceTypes - allowFhirApi + - protected - expiresAt - createdAt - revokedAt @@ -9734,6 +9793,9 @@ components: - accessCount - active - token + - passphrase + - shareUrl + - qrUrl additionalProperties: false error: type: "null" @@ -9774,6 +9836,8 @@ components: type: string allowFhirApi: type: boolean + protected: + type: boolean expiresAt: type: string createdAt: @@ -9797,6 +9861,7 @@ components: - rangeEnd - resourceTypes - allowFhirApi + - protected - expiresAt - createdAt - revokedAt @@ -9845,6 +9910,31 @@ components: - data - error additionalProperties: false + ShareLinkUnlock: + type: object + properties: + data: + type: object + properties: + unlocked: + type: boolean + enum: + - true + required: + - unlocked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false LoginResponse: type: object properties: diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 22c4e76eb..118d3884d 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -8,7 +8,10 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; -import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { + createShareLinkSchema, + unlockShareLinkSchema, +} from "@/lib/validations/clinician-share-link"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // v1.11.0 — clinician share-link owner-facing summary (never the raw token). @@ -19,6 +22,9 @@ const shareLinkSummary = z.object({ rangeEnd: z.string().nullable(), resourceTypes: z.array(z.string()), allowFhirApi: z.boolean(), + // v1.18.7 — whether a passphrase second factor guards this link. Always true + // for links created on v1.18.7+; false only for legacy links. + protected: z.boolean(), expiresAt: z.string(), createdAt: z.string(), revokedAt: z.string().nullable(), @@ -31,6 +37,18 @@ const shareLinkCreatedResponse = shareLinkSummary.extend({ token: z .string() .describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."), + // v1.18.7 — passphrase second factor + QR deep link, all returned ONCE. + passphrase: z + .string() + .describe( + "Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.", + ), + shareUrl: z.string().describe("Absolute `/c/` URL (no passphrase)."), + qrUrl: z + .string() + .describe( + "Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR payload; the fragment is never sent to the server.", + ), }); const shareLinkListResponse = z.object({ @@ -42,6 +60,11 @@ const shareLinkRevokedResponse = z.object({ revoked: z.boolean(), }); +// v1.18.7 — public passphrase-gate verify result. +const shareLinkUnlockResponse = z.object({ + unlocked: z.literal(true), +}); + export const healthRecordPaths: NonNullable = { "/api/export/health-record": { post: { @@ -80,7 +103,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Create a clinician share link (v1.11.0)", description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", requestBody: { required: true, content: { @@ -148,6 +171,42 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/api/c/{token}/unlock": { + post: { + tags: ["Export"], + summary: "Verify a share-link passphrase (v1.18.7, public)", + description: + "Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401.", + requestParams: { path: z.object({ token: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: unlockShareLinkSchema }, + }, + }, + responses: { + "200": { + description: + "Passphrase accepted; short-lived token-scoped unlock cookie set.", + content: { + "application/json": { + schema: dataEnvelope( + shareLinkUnlockResponse, + "ShareLinkUnlock", + ), + }, + }, + }, + // Spread the standard responses first, then override 401 with the + // share-specific blunt-reject description. + ...stdResponses, + "401": { + description: "Invalid passphrase (blunt — no failure detail leaked).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"],