diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf449faec..02090d4d6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -998,8 +998,11 @@ paths: 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." + write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's + own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports + `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `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: @@ -1096,6 +1099,13 @@ paths: - MedicationAdministration allowFhirApi: type: boolean + documentIds: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 64 expiresAt: type: string format: date-time @@ -1204,6 +1214,57 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /c/{token}/d/{id}: + get: + tags: + - Export + summary: Serve a shared document blob (v1.28, public) + description: "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the + ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in + the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected + link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the + same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: + Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. + Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); + PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + - in: path + name: id + schema: + type: string + description: A document id on the link's frozen set. + required: true + description: A document id on the link's frozen set. + responses: + "200": + description: The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` + for Class B (attachment). + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the + frozen set). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link serve rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: @@ -12896,6 +12957,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -12933,6 +12996,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt @@ -12985,6 +13049,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -13009,6 +13075,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index b69c60843..3ea7f445e 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -16,12 +16,21 @@ vi.mock("@/lib/doctor-report-data", () => ({ vi.mock("@/lib/validations/doctor-report-prefs", () => ({ parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}), })); +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLinkDocument: { findMany: vi.fn() }, + }, +})); import { loadShareViewData } from "../share-view-data"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { prisma } from "@/lib/db"; import type { ShareContext } from "../resolve-share-token"; const collect = collectDoctorReportData as ReturnType; +const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType< + typeof vi.fn +>; function ctx(overrides: Partial = {}): ShareContext { return { @@ -44,6 +53,7 @@ describe("loadShareViewData — KVNR default OFF", () => { collect.mockResolvedValue({ patient: { displayName: "Shared record" }, }); + findDocs.mockResolvedValue([]); }); it("scopes the aggregator to the OWNER from the share context, never the wire", async () => { @@ -71,6 +81,63 @@ describe("loadShareViewData — KVNR default OFF", () => { expect(patient).not.toHaveProperty("kvnr"); }); + it("lists the frozen document set as metadata only (never bytes)", async () => { + findDocs.mockResolvedValue([ + { + document: { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: new Date("2026-01-15T00:00:00Z"), + byteSize: 12345, + mimeType: "application/pdf", + }, + }, + { + document: { + id: "doc-b", + title: null, + kind: "OTHER", + documentDate: null, + byteSize: 6789, + mimeType: "application/msword", + }, + }, + ]); + + const { documents } = await loadShareViewData(ctx()); + + // Scoped to THIS link + owner + live rows; the blob column is never named. + const arg = findDocs.mock.calls[0]![0] as { + where: Record; + select: { document: { select: Record } }; + }; + expect(arg.where).toEqual({ + shareLinkId: "link-1", + document: { userId: "owner-1", deletedAt: null }, + }); + expect(arg.select.document.select).not.toHaveProperty("contentEncrypted"); + + expect(documents).toEqual([ + { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: "2026-01-15", + byteSize: 12345, + servingClass: "inline", + }, + { + id: "doc-b", + title: null, + kind: "OTHER", + documentDate: null, + byteSize: 6789, + servingClass: "attachment", + }, + ]); + }); + it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => { const now = Date.now(); await loadShareViewData(ctx({ rangeEnd: null })); diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 6ecaba023..1a5285250 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -12,19 +12,40 @@ * them under an explicit "not a clinical assessment" card), but they are read * straight from the aggregator — no AI call, no coach, no insight generation. */ +import { prisma } from "@/lib/db"; import { collectDoctorReportData, type DoctorReportData, type DoctorReportRange, } from "@/lib/doctor-report-data"; +import { servingClassFor } from "@/lib/documents/upload-policy"; +import type { DocumentServingClass } from "@/lib/documents/upload-policy"; import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; import type { ShareContext } from "@/lib/clinician-share/resolve-share-token"; +/** + * v1.28 — metadata for one document on the share's frozen set. NEVER carries + * bytes: the share serve route (`/c//d/`) is the only decrypt path + * (P3-D5). The recipient view renders this list and points each entry at that + * route (Class A inline preview / Class B download). + */ +export interface ShareViewDocument { + id: string; + title: string | null; + kind: string; + /** Filing date (YYYY-MM-DD at UTC) or null. */ + documentDate: string | null; + byteSize: number; + servingClass: DocumentServingClass; +} + export interface ShareViewData { /** The aggregated, owner-scoped report payload over the frozen window. */ report: DoctorReportData; /** The resolved section toggles (mood opt-in, defaults otherwise). */ sections: ReturnType; + /** v1.28 — the hand-picked documents on this link (metadata only). */ + documents: ShareViewDocument[]; } /** @@ -53,9 +74,51 @@ export async function loadShareViewData( const sections = parseDoctorReportPrefs(context.sectionsJson); const range = frozenRange(context); - const report = await collectDoctorReportData(context.ownerUserId, range, { - sections, + const [report, documents] = await Promise.all([ + collectDoctorReportData(context.ownerUserId, range, { sections }), + loadShareDocuments(context), + ]); + + return { report, sections, documents }; +} + +/** + * The frozen document set for a resolved share, as metadata only. Scoped to + * the link's membership rows AND the owner (defence in depth) AND live rows — + * a document the owner soft-deleted after sharing drops out of the list, just + * as it 404s at the serve route. The blob column is never selected. + */ +async function loadShareDocuments( + context: ShareContext, +): Promise { + const rows = await prisma.clinicianShareLinkDocument.findMany({ + where: { + shareLinkId: context.shareLinkId, + document: { userId: context.ownerUserId, deletedAt: null }, + }, + select: { + document: { + select: { + id: true, + title: true, + kind: true, + documentDate: true, + byteSize: true, + mimeType: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, }); - return { report, sections }; + return rows.map(({ document }) => ({ + id: document.id, + title: document.title, + kind: document.kind, + documentDate: document.documentDate + ? document.documentDate.toISOString().slice(0, 10) + : null, + byteSize: document.byteSize, + servingClass: servingClassFor(document.mimeType), + })); } diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 048f52f1c..57137b7b8 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -25,6 +25,9 @@ const shareLinkSummary = z.object({ // 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(), + // v1.28 — how many documents this share carries. Never the ids, never the + // bytes; the share serve route is the only decrypt path. + documentCount: z.number(), expiresAt: z.string(), createdAt: z.string(), revokedAt: z.string().nullable(), @@ -103,7 +106,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. 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.", + "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. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `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: { @@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/c/{token}/d/{id}": { + get: { + tags: ["Export"], + summary: "Serve a shared document blob (v1.28, public)", + description: + "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + id: z.string().describe("A document id on the link's frozen set."), + }), + }, + responses: { + "200": { + description: + "The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` for Class B (attachment).", + content: { + "application/octet-stream": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the frozen set).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link serve rate limit exceeded.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"],