mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
2258cbb489
commit
6c3b8be099
5 files changed
+590
No files matched your search
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user