mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
d75a538614
commit
e057589574
2 files changed
+152
-3
No files matched your search
+91
-1
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user