feat(share-links): expose the frozen document set on the share view + OpenAPI

P3-1-T6. loadShareViewData now returns the link's document set as metadata only
(id, title, kind, documentDate, byteSize, servingClass) — scoped to the
membership rows, the owner, and live rows; the blob column is never selected
(the serve route is the only decrypt path). Register documentCount on the share
summary, documentIds on create, and the public /c/{token}/d/{id} serve route in
the OpenAPI contract; regenerate docs/api/openapi.yaml.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 18:47:02 +02:00
1 parent 0885f331b0
commit de84994a95
4 files changed
+240 -6

No files matched your search

+69 -2
View File
@@ -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:<userId>`, 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:<userId>`, 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
@@ -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<typeof vi.fn>;
const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType<
typeof vi.fn
>;
function ctx(overrides: Partial<ShareContext> = {}): 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<string, unknown>;
select: { document: { select: Record<string, boolean> } };
};
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 }));
+66 -3
View File
@@ -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/<token>/d/<id>`) 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<typeof parseDoctorReportPrefs>;
/** 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<ShareViewDocument[]> {
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),
}));
}
+38 -1
View File
@@ -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<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. 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.",
"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:<userId>`, 20/h). Strict: unknown keys 422.",
requestBody: {
required: true,
content: {
@@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],