Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated

# Conflicts:
#	prisma/schema.prisma
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 21:03:04 +02:00
commit 9ff39b5846
16 files changed
+1490 -41

No files matched your search

@@ -0,0 +1,188 @@
import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import { stripImageMetadata } from "@/lib/clinician-share/exif-strip";
/**
* The stripper walks container structure and drops metadata carriers without
* decoding pixels. These fixtures are hand-built minimal-but-valid segment
* streams so we can assert exactly which markers survive.
*/
function u16be(n: number): Buffer {
const b = Buffer.alloc(2);
b.writeUInt16BE(n);
return b;
}
function u32be(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
}
function u32le(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32LE(n);
return b;
}
/** A JPEG segment: FF <marker> <len BE incl. the 2 len bytes> <payload>. */
function jpegSegment(marker: number, payload: Buffer): Buffer {
return Buffer.concat([
Buffer.from([0xff, marker]),
u16be(payload.length + 2),
payload,
]);
}
describe("stripImageMetadata — passthrough", () => {
it("leaves non-strippable types byte-for-byte unchanged", () => {
const pdf = Buffer.from("%PDF-1.4 fake pdf body");
expect(stripImageMetadata(pdf, "application/pdf")).toBe(pdf);
const tiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]);
expect(stripImageMetadata(tiff, "image/tiff")).toBe(tiff);
const heic = Buffer.from("ftyp-heic-fake");
expect(stripImageMetadata(heic, "image/heic")).toBe(heic);
});
it("returns malformed input for the claimed type unchanged (fail-safe)", () => {
const notJpeg = Buffer.from([0x00, 0x01, 0x02, 0x03]);
expect(stripImageMetadata(notJpeg, "image/jpeg")).toBe(notJpeg);
});
});
describe("stripImageMetadata — JPEG", () => {
// SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan.
const soi = Buffer.from([0xff, 0xd8]);
const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0"));
const exifPayload = Buffer.concat([
Buffer.from("Exif\0\0"),
// A recognisable GPS marker string we can assert is gone.
Buffer.from("GPSLatitude=48.137 GPSLongitude=11.575"),
]);
const app1 = jpegSegment(0xe1, exifPayload);
const iccPayload = Buffer.concat([
Buffer.from("ICC_PROFILE\0"),
Buffer.from([1, 2, 3, 4]),
]);
const app2 = jpegSegment(0xe2, iccPayload);
const com = jpegSegment(0xfe, Buffer.from("private comment"));
const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03]));
const sosAndScan = Buffer.concat([
Buffer.from([0xff, 0xda]),
u16be(2 + 6),
Buffer.from([0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]),
Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]), // entropy-coded scan bytes
]);
const jpeg = Buffer.concat([soi, app0, app1, app2, com, dqt, sosAndScan]);
it("removes EXIF/GPS, XMP-carrying APP1, and the comment segment", () => {
const out = stripImageMetadata(jpeg, "image/jpeg");
expect(out.includes(Buffer.from("Exif\0\0"))).toBe(false);
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
expect(out.includes(Buffer.from("private comment"))).toBe(false);
});
it("keeps JFIF (APP0), ICC (APP2), the quantisation table, and the scan data", () => {
const out = stripImageMetadata(jpeg, "image/jpeg");
expect(out.subarray(0, 2)).toEqual(soi);
expect(out.includes(Buffer.from("JFIF"))).toBe(true);
expect(out.includes(Buffer.from("ICC_PROFILE"))).toBe(true);
// The DQT segment and the entropy-coded scan tail must survive verbatim.
expect(out.includes(dqt)).toBe(true);
expect(out.includes(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]))).toBe(true);
// Output is strictly smaller (metadata removed).
expect(out.length).toBeLessThan(jpeg.length);
});
});
describe("stripImageMetadata — PNG", () => {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function pngChunk(type: string, data: Buffer): Buffer {
return Buffer.concat([
u32be(data.length),
Buffer.from(type, "ascii"),
data,
u32be(0),
]);
}
const ihdr = pngChunk(
"IHDR",
Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]),
);
const exif = pngChunk("eXIf", Buffer.from("GPSInfo private-location"));
const text = pngChunk("tEXt", Buffer.from("Comment\0secret note"));
const itxt = pngChunk(
"iTXt",
Buffer.from("XML:com.adobe.xmp\0\0\0\0\0<x:xmpmeta/>"),
);
const gama = pngChunk("gAMA", Buffer.from([0, 0, 0x8a, 0x3d]));
const idat = pngChunk("IDAT", Buffer.from([0x78, 0x9c, 0x01, 0x00]));
const iend = pngChunk("IEND", Buffer.alloc(0));
const png = Buffer.concat([sig, ihdr, exif, text, itxt, gama, idat, iend]);
it("removes eXIf, tEXt, and iTXt (XMP) chunks", () => {
const out = stripImageMetadata(png, "image/png");
expect(out.includes(Buffer.from("GPSInfo"))).toBe(false);
expect(out.includes(Buffer.from("secret note"))).toBe(false);
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
});
it("keeps IHDR, gAMA, IDAT, IEND and the signature", () => {
const out = stripImageMetadata(png, "image/png");
expect(out.subarray(0, 8)).toEqual(sig);
expect(out.includes(gama)).toBe(true);
expect(out.includes(idat)).toBe(true);
expect(out.includes(iend)).toBe(true);
expect(out.length).toBeLessThan(png.length);
});
});
describe("stripImageMetadata — WebP", () => {
function webpChunk(fourcc: string, data: Buffer): Buffer {
const padded =
data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data;
return Buffer.concat([
Buffer.from(fourcc, "ascii"),
u32le(data.length),
padded,
]);
}
// VP8X with EXIF (0x08) + XMP (0x04) flags set, an image chunk, plus EXIF/XMP.
const vp8xFlags = Buffer.from([0x08 | 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
const vp8x = webpChunk("VP8X", vp8xFlags);
const vp8 = webpChunk("VP8 ", Buffer.from([0x11, 0x22, 0x33, 0x44]));
const exif = webpChunk("EXIF", Buffer.from("GPSLocation private"));
const xmp = webpChunk("XMP ", Buffer.from("<x:xmpmeta>secret</x:xmpmeta>"));
const body = Buffer.concat([vp8x, vp8, exif, xmp]);
const header = Buffer.concat([
Buffer.from("RIFF", "ascii"),
u32le(4 + body.length),
Buffer.from("WEBP", "ascii"),
]);
const webp = Buffer.concat([header, body]);
it("removes the EXIF and XMP chunks", () => {
const out = stripImageMetadata(webp, "image/webp");
expect(out.includes(Buffer.from("GPSLocation"))).toBe(false);
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
});
it("keeps the image chunk and clears the VP8X EXIF/XMP flag bits", () => {
const out = stripImageMetadata(webp, "image/webp");
expect(out.includes(Buffer.from([0x11, 0x22, 0x33, 0x44]))).toBe(true);
// Locate VP8X and check its flag byte: EXIF/XMP bits cleared.
const vp8xIdx = out.indexOf(Buffer.from("VP8X", "ascii"));
expect(vp8xIdx).toBeGreaterThan(0);
const flags = out[vp8xIdx + 8];
expect(flags & 0x08).toBe(0);
expect(flags & 0x04).toBe(0);
});
it("rewrites the RIFF size header to match the trimmed body", () => {
const out = stripImageMetadata(webp, "image/webp");
expect(out.toString("ascii", 0, 4)).toBe("RIFF");
expect(out.toString("ascii", 8, 12)).toBe("WEBP");
// Declared size == 4 (for "WEBP") + the remaining chunk bytes.
expect(out.readUInt32LE(4)).toBe(out.length - 8);
});
});
@@ -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 }));
+228
View File
@@ -0,0 +1,228 @@
/**
* v1.28 (document vault, Phase 3) — dependency-free metadata stripper for the
* share egress boundary (P3-D4).
*
* A recipient of a shared document should not inadvertently receive the
* owner's camera EXIF / GPS / XMP metadata. This module removes those markers
* from the common camera-photo raster formats (JPEG, PNG, WebP) as the bytes
* are served through the share route — NEVER at rest. The owner's stored
* original is the artefact and is untouched (v1 §6.5): the strip is a pure
* transform applied to a decrypted copy on its way out.
*
* Pure JS, no `sharp` / `libvips` / native decode (repo guardrail G6). We do
* not decode or re-encode pixels: we walk the container's segment/chunk
* structure and drop exactly the metadata carriers, copying every image-data
* segment through verbatim. Output renders identically; only the metadata is
* gone.
*
* Honest limits (documented in the operator docs + the share copy): PDF, TIFF,
* HEIC/HEIF, and Office formats are out of this stripper's reach and pass
* through unchanged. Those leave the origin as attachment-class downloads.
* Any format we don't recognise — or any parse that runs off the end of the
* buffer — is returned byte-for-byte unchanged (fail-safe: never corrupt a
* document we can't confidently rewrite).
*/
import { Buffer } from "node:buffer";
/** MIME types this stripper actively rewrites. Everything else passes through. */
const STRIPPABLE_MIMES: ReadonlySet<string> = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
/**
* Strip EXIF/XMP/GPS metadata from a raster image's bytes, dispatched by the
* document's stored (magic-byte-sniffed) MIME type. Returns the original
* buffer unchanged for any non-strippable type, and — fail-safe — for any
* input that does not parse cleanly as the claimed format.
*/
export function stripImageMetadata(bytes: Buffer, mimeType: string): Buffer {
if (!STRIPPABLE_MIMES.has(mimeType)) return bytes;
try {
switch (mimeType) {
case "image/jpeg":
return stripJpeg(bytes);
case "image/png":
return stripPng(bytes);
case "image/webp":
return stripWebp(bytes);
default:
return bytes;
}
} catch {
// Never corrupt a document because the stripper tripped: on any parse
// error, serve the original bytes. The serving-class posture (nosniff +
// true type) is the security boundary; the strip is a privacy nicety.
return bytes;
}
}
// ─── JPEG ────────────────────────────────────────────────────────────────────
//
// A JPEG is `FFD8` (SOI) followed by marker segments. Each non-entropy marker
// is `FF <marker> <len-hi> <len-lo> <payload…>` where `len` counts the two
// length bytes + payload. We drop the metadata-bearing APP segments and the
// comment segment, copy every other segment verbatim, and once we hit SOS
// (`FFDA`) copy the rest of the file (entropy-coded scan data) unchanged.
//
// Dropped: APP1 (`FFE1`, Exif + XMP), APP13 (`FFED`, Photoshop / IPTC), and
// COM (`FFFE`, free-text comment). Kept: APP0 (`FFE0`, JFIF — some decoders
// expect it) and APP2 (`FFE2`, ICC colour profile — dropping it would shift
// colours), plus every structural segment (quantisation/Huffman tables, frame
// headers, …).
const JPEG_DROP_MARKERS: ReadonlySet<number> = new Set([
0xe1, // APP1 — Exif, XMP
0xed, // APP13 — Photoshop / IPTC-IIM
0xfe, // COM — comment
]);
function stripJpeg(bytes: Buffer): Buffer {
if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
return bytes; // not a JPEG SOI — pass through
}
const out: Buffer[] = [bytes.subarray(0, 2)]; // SOI
let offset = 2;
while (offset + 1 < bytes.length) {
if (bytes[offset] !== 0xff) {
// Misaligned — bail and keep the remainder verbatim (fail-safe).
out.push(bytes.subarray(offset));
return Buffer.concat(out);
}
const marker = bytes[offset + 1];
// Start Of Scan: everything after is entropy-coded image data. Copy it all.
if (marker === 0xda) {
out.push(bytes.subarray(offset));
return Buffer.concat(out);
}
// Standalone markers without a length payload (RSTn, TEM) — copy the 2
// marker bytes and continue.
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
out.push(bytes.subarray(offset, offset + 2));
offset += 2;
continue;
}
// Length-prefixed segment.
if (offset + 4 > bytes.length) {
out.push(bytes.subarray(offset));
return Buffer.concat(out);
}
const segLen = bytes.readUInt16BE(offset + 2);
const segEnd = offset + 2 + segLen;
if (segLen < 2 || segEnd > bytes.length) {
// Corrupt length — keep the remainder verbatim rather than lose data.
out.push(bytes.subarray(offset));
return Buffer.concat(out);
}
if (!JPEG_DROP_MARKERS.has(marker)) {
out.push(bytes.subarray(offset, segEnd));
}
offset = segEnd;
}
return Buffer.concat(out);
}
// ─── PNG ─────────────────────────────────────────────────────────────────────
//
// An 8-byte signature followed by `length(4 BE) type(4 ascii) data crc(4)`
// chunks. We drop the ancillary metadata chunks and copy everything else
// (IHDR, PLTE, IDAT, IEND, gAMA, cHRM, iCCP, sRGB, …) verbatim.
//
// Dropped: `eXIf` (EXIF), `tEXt` / `zTXt` / `iTXt` (text incl. XMP), `tIME`
// (last-modified timestamp).
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const PNG_DROP_CHUNKS: ReadonlySet<string> = new Set([
"eXIf",
"tEXt",
"zTXt",
"iTXt",
"tIME",
]);
function stripPng(bytes: Buffer): Buffer {
if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
return bytes;
}
const out: Buffer[] = [bytes.subarray(0, 8)];
let offset = 8;
while (offset + 8 <= bytes.length) {
const dataLen = bytes.readUInt32BE(offset);
const type = bytes.toString("ascii", offset + 4, offset + 8);
const chunkEnd = offset + 12 + dataLen; // len + type + data + crc
if (chunkEnd > bytes.length) {
// Truncated chunk — keep the remainder verbatim (fail-safe).
out.push(bytes.subarray(offset));
return Buffer.concat(out);
}
if (!PNG_DROP_CHUNKS.has(type)) {
out.push(bytes.subarray(offset, chunkEnd));
}
offset = chunkEnd;
if (type === "IEND") break; // nothing legitimate follows IEND
}
return Buffer.concat(out);
}
// ─── WebP ────────────────────────────────────────────────────────────────────
//
// A RIFF container: `"RIFF" <size LE> "WEBP"` then FourCC chunks
// (`<fourcc(4)> <size LE(4)> <payload…>`, payload padded to an even length).
// We drop the `EXIF` and `XMP ` chunks and, when the extended header `VP8X` is
// present, clear its EXIF/XMP presence flag bits so the rewritten file is
// self-consistent. The image-data chunks (VP8/VP8L/VP8X/ALPH/ANIM/ANMF) copy
// through verbatim.
function stripWebp(bytes: Buffer): Buffer {
if (
bytes.length < 12 ||
bytes.toString("ascii", 0, 4) !== "RIFF" ||
bytes.toString("ascii", 8, 12) !== "WEBP"
) {
return bytes;
}
const kept: Buffer[] = [];
let offset = 12;
while (offset + 8 <= bytes.length) {
const fourcc = bytes.toString("ascii", offset, offset + 4);
const chunkSize = bytes.readUInt32LE(offset + 4);
const padded = chunkSize + (chunkSize % 2); // chunks pad to even length
const payloadStart = offset + 8;
const chunkEnd = payloadStart + padded;
if (chunkEnd > bytes.length) {
// Truncated — keep the remainder verbatim (fail-safe).
kept.push(bytes.subarray(offset));
offset = bytes.length;
break;
}
if (fourcc === "EXIF" || fourcc === "XMP ") {
// Drop the metadata chunk entirely.
offset = chunkEnd;
continue;
}
if (fourcc === "VP8X") {
// Extended-format header: byte 0 of the payload carries the feature
// flags. Clear the EXIF (0x08) and XMP (0x04) presence bits so the
// header no longer advertises metadata we just removed.
const chunk = Buffer.from(bytes.subarray(offset, chunkEnd));
chunk[8] = chunk[8] & ~0x08 & ~0x04;
kept.push(chunk);
offset = chunkEnd;
continue;
}
kept.push(bytes.subarray(offset, chunkEnd));
offset = chunkEnd;
}
// Rebuild the RIFF header with the recomputed payload size (the sum of the
// kept chunks plus the trailing "WEBP" FourCC).
const body = Buffer.concat(kept);
const header = Buffer.alloc(12);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(4 + body.length, 4); // "WEBP" + chunk bytes
header.write("WEBP", 8, "ascii");
return Buffer.concat([header, body]);
}
+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"],
@@ -33,10 +33,22 @@ export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000;
/**
* v1.28 — the most documents a single share may carry. A share is a hand-picked
* handover, not a bulk dump; the cap bounds the create-time ownership check and
* the public view's per-blob fetch fan-out. The ids are validated as the
* caller's own LIVE documents in the route (a schema can only bound the shape).
*/
export const SHARE_LINK_MAX_DOCUMENTS = 50;
/**
* Create payload. `expiresAt` is an absolute ISO instant, must be in the
* future, and at most `SHARE_LINK_MAX_DAYS` ahead of now. `rangeStart` /
* `rangeEnd` are the frozen reporting window (rangeEnd null = rolling).
* `documentIds` (optional) is the hand-picked document set, frozen write-once
* at create; each id is ownership-checked in the route before any row is
* written. A documents-only share (empty report sections + a non-empty
* `documentIds`) is valid.
*/
export const createShareLinkSchema = z
.object({
@@ -46,6 +58,10 @@ export const createShareLinkSchema = z
sections: exportSectionsSchema.optional(),
resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(),
allowFhirApi: z.boolean().optional(),
documentIds: z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
.optional(),
expiresAt: z.iso
.datetime({ offset: true })
.refine((v) => new Date(v).getTime() > Date.now(), {