feat(share-links): owner clinician share-link lifecycle API

Authenticated owner routes to create, list, and revoke a ClinicianShareLink.
Create mints an hls_ token (192-bit), stores only its HMAC hash, and returns
the raw token exactly once; the scope columns (window, sections, FHIR resource
types, API toggle) are frozen write-once. expiresAt is required and capped at
SHARE_LINK_MAX_DAYS (90). Revoke sets revokedAt and seals a cross-user id as
404. userId is narrowed from auth; rate-limited; strict Zod rejects unknown
keys. This is the owner lifecycle only — the public resolver/view is separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 01:05:06 +02:00
1 parent 2258cbb489
commit 6c3b8be099
5 files changed
+590

No files matched your search

+109
View File
@@ -46,6 +46,7 @@ import {
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import {
DERIVED_METRIC_IDS,
@@ -1848,12 +1849,53 @@ const insightsLayoutSchema = z
// v1.7.0 — health-record export selection. Strict shape: unknown keys
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
// (the owner is always narrowed from the session/Bearer). `expiresAt` is
// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen
// write-once at creation.
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). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.",
});
exportSelectionSchema.meta({
id: "HealthRecordExportRequest",
description:
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
});
// v1.11.0 — clinician share-link owner-facing summary (never the raw token).
const shareLinkSummary = z.object({
id: z.string(),
label: z.string(),
rangeStart: z.string(),
rangeEnd: z.string().nullable(),
resourceTypes: z.array(z.string()),
allowFhirApi: z.boolean(),
expiresAt: z.string(),
createdAt: z.string(),
revokedAt: z.string().nullable(),
lastAccessAt: z.string().nullable(),
accessCount: z.number(),
active: z.boolean(),
});
const shareLinkCreatedResponse = shareLinkSummary.extend({
token: z
.string()
.describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."),
});
const shareLinkListResponse = z.object({
shareLinks: z.array(shareLinkSummary),
});
const shareLinkRevokedResponse = z.object({
id: z.string(),
revoked: z.boolean(),
});
// v1.10.2 — live capability / discovery response. Every list is sourced
// server-side from the canonical registry it documents, so the wire shape
// here is the contract; the runtime values are authoritative and never
@@ -1993,6 +2035,73 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/share-links": {
post: {
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.",
requestBody: {
required: true,
content: {
"application/json": { schema: createShareLinkSchema },
},
},
responses: {
"201": {
description:
"Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response.",
content: {
"application/json": {
schema: dataEnvelope(shareLinkCreatedResponse, "ShareLinkCreated"),
},
},
},
...stdResponses,
},
},
get: {
tags: ["Export"],
summary: "List own clinician share links (v1.11.0)",
description:
"Owner-only. Returns the caller's own share links (never the raw token — it is unrecoverable after creation). Auth via cookie or Bearer.",
responses: {
"200": {
description: "Share links owned by the caller.",
content: {
"application/json": {
schema: dataEnvelope(shareLinkListResponse, "ShareLinkList"),
},
},
},
...stdResponses,
},
},
},
"/api/share-links/{id}": {
delete: {
tags: ["Export"],
summary: "Revoke a clinician share link (v1.11.0)",
description:
"Owner-only. Sets `revokedAt` on the caller's own link. A cross-user or unknown id is sealed as 404. Auth via cookie or Bearer; rate-limited.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Link revoked.",
content: {
"application/json": {
schema: dataEnvelope(shareLinkRevokedResponse, "ShareLinkRevoked"),
},
},
},
"404": {
description: "Link not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/auth/login": {
post: {
tags: ["Auth"],