Document the passphrase fields and unlock route in the API contract

Extend the share-link create response with the passphrase, share URL, and
QR deep link, and add the protected flag to the summary. Document the
public POST /api/c/{token}/unlock passphrase-gate route. Regenerate the
committed OpenAPI YAML to match.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:03:59 +02:00
1 parent d75a538614
commit e057589574
2 files changed
+152 -3

No files matched your search

+91 -1
View File
@@ -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:<userId>`, 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/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); 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/<token>` URL (no passphrase).
qrUrl:
type: string
description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). 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:
+61 -2
View File
@@ -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/<token>` URL (no passphrase)."),
qrUrl: z
.string()
.describe(
"Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). 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<ZodOpenApiObject["paths"]> = {
"/api/export/health-record": {
post: {
@@ -80,7 +103,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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:<userId>`, 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:<userId>`, 20/h). Strict: unknown keys 422.",
requestBody: {
required: true,
content: {
@@ -148,6 +171,42 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); 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"],