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

+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:
@@ -13087,6 +13148,8 @@ components:
type: boolean
protected:
type: boolean
documentCount:
type: number
expiresAt:
type: string
createdAt:
@@ -13124,6 +13187,7 @@ components:
- resourceTypes
- allowFhirApi
- protected
- documentCount
- expiresAt
- createdAt
- revokedAt
@@ -13176,6 +13240,8 @@ components:
type: boolean
protected:
type: boolean
documentCount:
type: number
expiresAt:
type: string
createdAt:
@@ -13200,6 +13266,7 @@ components:
- resourceTypes
- allowFhirApi
- protected
- documentCount
- expiresAt
- createdAt
- revokedAt
@@ -0,0 +1,45 @@
-- Document vault, Phase 3: attach hand-picked documents to a clinician share
-- link.
--
-- `clinician_share_link_documents` — frozen, write-once membership rows
-- binding a stored document to a share link. The document set a share
-- exposes is modelled as this join, never a mutable column on the link, so
-- it inherits the link's frozen-scope discipline: rows are created in the
-- same transaction as the link, each document is owner-checked at create,
-- and the set is never widened afterwards. The public share serve route
-- serves ONLY a document id present in this set for the resolved token.
--
-- Both foreign keys cascade: deleting the link or the underlying document
-- removes the membership cleanly (a shared document the owner later deletes
-- simply disappears from the link).
--
-- Additive; applies clean on a 0228 database and on a fresh database as part
-- of the ordered migration chain.
CREATE TABLE "clinician_share_link_documents" (
"id" TEXT NOT NULL,
"share_link_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key"
ON "clinician_share_link_documents" ("share_link_id", "document_id");
CREATE INDEX "clinician_share_link_documents_share_link_id_idx"
ON "clinician_share_link_documents" ("share_link_id");
CREATE INDEX "clinician_share_link_documents_document_id_idx"
ON "clinician_share_link_documents" ("document_id");
ALTER TABLE "clinician_share_link_documents"
ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey"
FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "clinician_share_link_documents"
ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+32 -1
View File
@@ -4853,13 +4853,41 @@ model ClinicianShareLink {
lastAccessAt DateTime? @map("last_access_at")
accessCount Int @default(0) @map("access_count")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// v1.28 — the hand-picked document set this link exposes (0..N). A relation,
/// not a column: frozen write-once at create, owner-checked, never widened.
documents ClinicianShareLinkDocument[]
@@index([userId, createdAt(sort: Desc)])
@@index([expiresAt])
@@map("clinician_share_links")
}
/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a
/// stored document to a clinician share link. The document set a share exposes
/// is modelled as this relation, never a mutable column: the rows are created
/// in the same transaction as the link, each `documentId` is validated as the
/// caller's own live document at create time, and the set is never widened
/// afterwards. The public share serve route serves ONLY a document id present
/// in this set for the resolved token — a guessed / foreign id is a flat 404.
/// Both foreign keys cascade: deleting the link or the underlying document
/// removes the membership cleanly (a shared document the owner later deletes
/// simply disappears from the link).
model ClinicianShareLinkDocument {
id String @id @default(cuid())
shareLinkId String @map("share_link_id")
documentId String @map("document_id")
createdAt DateTime @default(now()) @map("created_at")
shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([shareLinkId, documentId])
@@index([shareLinkId])
@@index([documentId])
@@map("clinician_share_link_documents")
}
// v1.11.0 W3 — period-narrative cache (Pillar P1).
//
// One durable, typed row per (user, period, locale) carrying the latest
@@ -5819,6 +5847,9 @@ model InboundDocument {
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
/// Cascades: deleting this document removes it from every share it was on.
shareLinks ClinicianShareLinkDocument[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -86,3 +86,42 @@ describe("proxy.ts document serve-route framing carve-out", () => {
);
});
});
describe("proxy.ts share document serve-route framing carve-out", () => {
const shareToken = "hls_000000000000000000000000000000000000000000000000";
it("allows same-origin framing on the /c/<token>/d/<id> serve path", () => {
const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`));
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
const csp = res.headers.get("content-security-policy") ?? "";
expect(csp).toContain("frame-ancestors 'self'");
expect(csp).toContain("default-src 'none'");
// No sandbox (Chromium downloads sandboxed PDFs) and no script sources.
expect(csp).not.toMatch(/sandbox/);
expect(csp).not.toMatch(/script-src/);
});
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
const res = proxy(makeRequest(`/c/${shareToken}`));
// The clinician view is a PAGE — it must not be framable and keeps the
// page CSP; only the blob subresource under it takes the serve posture.
expect(res.headers.get("x-frame-options")).toBe("DENY");
expect(res.headers.get("content-security-policy") ?? "").toContain(
"frame-ancestors 'none'",
);
});
it("does not match nested, suffixed, or lookalike share serve paths", () => {
for (const path of [
`/c/${shareToken}/d/doc123/extra`, // extra trailing segment
`/c/${shareToken}/d/doc123x/more/deep`, // deep suffix
`/c/${shareToken}/dx/doc123`, // wrong segment name
`/c/${shareToken}/d`, // missing id
`/c/${shareToken}/d/`, // empty id
"/c//d/doc123", // empty token (traversal-ish)
]) {
const res = proxy(makeRequest(path));
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
}
});
});
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
updateMany: vi.fn(),
},
inboundDocument: {
findMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
@@ -81,6 +84,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
revokedAt: null,
lastAccessAt: null,
accessCount: 0,
_count: { documents: 0 },
...overrides,
};
}
@@ -178,6 +182,84 @@ describe("POST /api/share-links — create", () => {
expect(res.status).toBe(422);
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
it("attaches own live documents in the create transaction (frozen write-once)", async () => {
// The ownership probe returns exactly the requested (deduped) ids as live.
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
{ id: "doc-a" },
{ id: "doc-b" },
] as never);
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
storedRow({ _count: { documents: 2 } }) as never,
);
const res = await POST(
postReq(validBody({ documentIds: ["doc-a", "doc-b", "doc-a"] })),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { documentCount: number } };
expect(body.data.documentCount).toBe(2);
// Ownership was checked against the caller's own LIVE documents.
const probeArg = vi.mocked(prisma.inboundDocument.findMany).mock
.calls[0][0];
expect(probeArg!.where).toEqual({
id: { in: ["doc-a", "doc-b"] }, // deduped
userId: "user-1",
deletedAt: null,
});
// Membership rows are created NESTED in the same create call (one txn),
// deduped, never widened afterwards.
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
.calls[0][0];
expect(createArg.data.documents).toEqual({
create: [{ documentId: "doc-a" }, { documentId: "doc-b" }],
});
});
it("rejects a foreign / deleted document id at create (422, no link minted)", async () => {
// Only one of the two requested ids resolves as the caller's own live doc.
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
{ id: "doc-a" },
] as never);
const res = await POST(
postReq(validBody({ documentIds: ["doc-a", "not-mine"] })),
);
expect(res.status).toBe(422);
// No share is ever minted with a document the caller does not own (B5).
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
it("rejects more than the document cap via strict schema (422)", async () => {
const tooMany = Array.from({ length: 51 }, (_, i) => `doc-${i}`);
const res = await POST(postReq(validBody({ documentIds: tooMany })));
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
it("allows a documents-only share (empty report sections)", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
{ id: "doc-a" },
] as never);
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
);
const res = await POST(
postReq(
validBody({
resourceTypes: [],
allowFhirApi: false,
documentIds: ["doc-a"],
}),
),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { documentCount: number } };
expect(body.data.documentCount).toBe(1);
});
});
describe("GET /api/share-links — list", () => {
+52 -28
View File
@@ -54,6 +54,27 @@ function resolveOrigin(request: NextRequest): string {
return `${proto}://${host}`;
}
/**
* The row selection every owner-facing projection reads. `_count.documents`
* surfaces the size of the frozen document set without ever loading the ids
* (let alone the blobs) — the metadata-only discipline (P3-D5).
*/
const shareLinkSummarySelect = {
id: true,
label: true,
rangeStart: true,
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
lastAccessAt: true,
accessCount: true,
_count: { select: { documents: true } },
} as const;
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -68,6 +89,7 @@ function toSummary(row: {
revokedAt: Date | null;
lastAccessAt: Date | null;
accessCount: number;
_count: { documents: number };
}) {
return {
id: row.id,
@@ -79,6 +101,9 @@ function toSummary(row: {
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
protected: row.passphraseHash !== null,
// v1.28 — how many documents this share carries (never the ids, never the
// bytes; the serve route is the only decrypt path).
documentCount: row._count.documents,
expiresAt: row.expiresAt.toISOString(),
createdAt: row.createdAt.toISOString(),
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
@@ -120,6 +145,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rawPassphrase = generatePassphrase();
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
// v1.28 — resolve the frozen document set. The ids are deduped (a client
// could repeat one, which the unique membership index would otherwise
// reject) and then validated as the caller's OWN live documents. A single
// foreign / deleted / unknown id seals the whole create as 422 — no share is
// ever minted with a document the caller does not own (B5). The rows are
// created in the SAME transaction as the link (nested create), so the
// membership is frozen write-once atomically with the scope columns.
const documentIds = Array.from(new Set(input.documentIds ?? []));
if (documentIds.length > 0) {
const owned = await prisma.inboundDocument.findMany({
where: { id: { in: documentIds }, userId: user.id, deletedAt: null },
select: { id: true },
});
if (owned.length !== documentIds.length) {
return apiError("One or more documents are not available to share", 422, {
errorCode: "share-link.documents.invalid",
});
}
}
// Field-by-field build — no mass assignment. Scope columns are written
// exactly once here and never updated.
const created = await prisma.clinicianShareLink.create({
@@ -134,21 +179,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
resourceTypes: input.resourceTypes ?? [],
allowFhirApi: input.allowFhirApi ?? false,
expiresAt: new Date(input.expiresAt),
documents:
documentIds.length > 0
? { create: documentIds.map((documentId) => ({ documentId })) }
: undefined,
},
select: {
id: true,
label: true,
rangeStart: true,
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
lastAccessAt: true,
accessCount: true,
},
select: shareLinkSummarySelect,
});
await auditLog("share-link.create", {
@@ -158,6 +194,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
shareLinkId: created.id,
resourceTypes: created.resourceTypes,
allowFhirApi: created.allowFhirApi,
documentCount: created._count.documents,
expiresAt: created.expiresAt.toISOString(),
},
});
@@ -188,20 +225,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.clinicianShareLink.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
select: {
id: true,
label: true,
rangeStart: true,
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
lastAccessAt: true,
accessCount: true,
},
select: shareLinkSummarySelect,
});
return apiSuccess({ shareLinks: rows.map(toSummary) });
@@ -0,0 +1,308 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
/**
* v1.28 (document vault, Phase 3) — public share-scoped document serve route
* `GET /c/[token]/d/[id]`.
*
* Load-bearing behaviour under test: it resolves ONLY via the share token
* (never a session), confines serving to the link's frozen document set (a
* foreign / unknown / soft-deleted id → flat 404), re-enforces the passphrase
* gate and revocation/expiry BEFORE any decrypt, applies the identical
* serving-class posture as the owner route (inline Class A / attachment Class
* B + octet-stream + nosniff + no-store, no route-level CSP), strips EXIF from
* raster images on egress, and fails closed on a decrypt error.
*/
// Reversible crypto stub (mirrors the owner /original route test) so the
// document codec round-trips without real keys.
const decryptMock = vi.fn((s: string) =>
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
);
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`,
decrypt: (s: string) => decryptMock(s),
}));
vi.mock("@/lib/db", () => ({
prisma: {
clinicianShareLinkDocument: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareGateState: vi.fn(),
resolveShareToken: vi.fn(),
}));
vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
unlockCookieName: (h: string) => `hls_unlock_${h.slice(0, 16)}`,
verifyUnlockValue: vi.fn(() => true),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 299, resetAt: Date.now() }),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import {
resolveShareGateState,
resolveShareToken,
} from "@/lib/clinician-share/resolve-share-token";
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { encryptDocumentToBytes } from "@/lib/documents/store";
const TOKEN = "hls_000000000000000000000000000000000000000000000000";
const OWNER = "owner-1";
const SHARE_LINK_ID = "link-1";
type Ctx = { params: Promise<{ token: string; id: string }> };
const callGet = GET as unknown as (
req: NextRequest,
ctx: Ctx,
) => Promise<Response>;
function makeReq(id: string): NextRequest {
return new NextRequest(new URL(`http://localhost/c/${TOKEN}/d/${id}`));
}
function ctx(id: string): Ctx {
return { params: Promise.resolve({ token: TOKEN, id }) };
}
/** Seed a membership row whose document decrypts to `bytes` of `mimeType`. */
function seedMembership(opts: {
id?: string;
userId?: string;
filename?: string | null;
mimeType: string;
bytes: Buffer;
deletedAt?: Date | null;
}) {
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue({
document: {
id: opts.id ?? "doc-1",
userId: opts.userId ?? OWNER,
filename: opts.filename ?? "report.pdf",
mimeType: opts.mimeType,
contentCodec: "base64v1",
contentEncrypted: encryptDocumentToBytes(opts.bytes),
deletedAt: opts.deletedAt ?? null,
},
} as never);
}
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
// A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment.
function jpegWithGps(): Buffer {
const payload = Buffer.concat([
Buffer.from("Exif\0\0"),
Buffer.from("GPSLatitude=48.137"),
]);
const app1 = Buffer.concat([
Buffer.from([0xff, 0xe1]),
(() => {
const b = Buffer.alloc(2);
b.writeUInt16BE(payload.length + 2);
return b;
})(),
payload,
]);
const sos = Buffer.concat([
Buffer.from([0xff, 0xda]),
Buffer.from([0x00, 0x08]),
Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]),
Buffer.from([0xaa, 0xbb]),
]);
return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]);
}
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((s: string) =>
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
);
vi.mocked(verifyUnlockValue).mockReturnValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 299,
resetAt: Date.now(),
} as never);
// Default: a live, unprotected link resolving to the owner scope.
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: "hash-1",
passphraseHash: null,
} as never);
vi.mocked(resolveShareToken).mockResolvedValue({
shareLinkId: SHARE_LINK_ID,
ownerUserId: OWNER,
label: "Clinic",
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 3_600_000),
} as never);
});
describe("GET /c/[token]/d/[id] — share document serve", () => {
it("serves a PDF inline through the token, confined to the frozen set", async () => {
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
expect(res.headers.get("Content-Disposition")).toContain("inline");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
// The response CSP is proxy-owned; the route sets none.
expect(res.headers.get("Content-Security-Policy")).toBeNull();
const out = Buffer.from(await res.arrayBuffer());
expect(out.equals(PDF_BYTES)).toBe(true);
// The membership lookup ties the id to THIS link (token-confined scope).
const arg = vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mock
.calls[0][0];
expect(arg!.where).toEqual({
shareLinkId_documentId: {
shareLinkId: SHARE_LINK_ID,
documentId: "doc-1",
},
});
});
it("strips EXIF/GPS from a shared JPEG on egress", async () => {
const jpeg = jpegWithGps();
seedMembership({ mimeType: "image/jpeg", bytes: jpeg, filename: "p.jpg" });
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(200);
const out = Buffer.from(await res.arrayBuffer());
// The GPS marker is gone; the image is strictly smaller than the original.
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
expect(out.length).toBeLessThan(jpeg.length);
// Still an inline image.
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
expect(res.headers.get("Content-Disposition")).toContain("inline");
});
it("serves a Class B document as an opaque attachment (never inline)", async () => {
seedMembership({
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
bytes: Buffer.from("docx payload"),
filename: "letter.docx",
});
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
const disposition = res.headers.get("Content-Disposition") ?? "";
expect(disposition.startsWith("attachment;")).toBe(true);
expect(disposition).toContain("letter.docx");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
});
it("never serves a would-be HTML/SVG payload inline (byte-class denies it)", async () => {
// HTML/SVG can never be stored (upload deny-list); even if a row carried
// such a type, the serving class forces attachment + octet-stream so it
// can never script in-origin.
for (const mime of ["text/html", "image/svg+xml"]) {
seedMembership({ mimeType: mime, bytes: Buffer.from("<svg/>") });
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
expect(res.headers.get("Content-Disposition")).toContain("attachment");
}
});
it("404s a document id not on the link's frozen set", async () => {
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue(
null as never,
);
const res = await callGet(makeReq("foreign"), ctx("foreign"));
expect(res.status).toBe(404);
});
it("404s a soft-deleted document even if the membership row survives", async () => {
seedMembership({
mimeType: "application/pdf",
bytes: PDF_BYTES,
deletedAt: new Date(),
});
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(404);
});
it("404s when the document owner does not match the token owner", async () => {
seedMembership({
mimeType: "application/pdf",
bytes: PDF_BYTES,
userId: "someone-else",
});
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(404);
});
it("serves NOTHING for a revoked/expired/unknown token (gate null) — no decrypt", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(404);
// Revocation is enforced BEFORE any decrypt or membership lookup.
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
expect(resolveShareToken).not.toHaveBeenCalled();
});
it("serves NOTHING for a protected link without a valid unlock cookie", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: "hash-1",
passphraseHash: "hash(pass)",
} as never);
vi.mocked(verifyUnlockValue).mockReturnValue(false);
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(404);
// The passphrase gate stands in front of any decrypt.
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
});
it("429s when the per-link serve rate limit trips", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now(),
} as never);
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(429);
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
});
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
decryptMock.mockImplementation(() => {
throw new Error("unknown key id");
});
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBeTruthy();
});
});
Binary file not shown.
@@ -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(), {
+18 -6
View File
@@ -313,12 +313,23 @@ export function proxy(request: NextRequest) {
const isDocumentServeRoute =
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
// v1.28 — the public share-scoped document serve route at
// `/c/<token>/d/<id>` is the share analogue of the owner `/original` route:
// it decrypts and serves a user-uploaded document that the clinician view
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
// NOT the `/c/` page CSP, which pins `frame-ancestors 'none'` and would
// refuse the preview embed. Anchored to EXACTLY two dynamic segments so a
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
// lookalike never inherits the carve-out.
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
// Both serve routes take the narrow, document-only header posture.
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
// Security headers
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set(
"X-Frame-Options",
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
);
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
@@ -405,8 +416,9 @@ export function proxy(request: NextRequest) {
// page-load. Avatars now live on the User row and serve from
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
// them.
const csp = isDocumentServeRoute
? // Vault serve route (see the X-Frame-Options note above): the
const csp = isServeRoute
? // Vault serve route (owner `/original` OR share `/c/<token>/d/<id>`; see
// the X-Frame-Options note above): the
// response is a user-uploaded document, not an app page.
// `default-src 'none'` — a served document may load nothing as if
// it were a page — and `frame-ancestors 'self'` permits exactly the
@@ -0,0 +1,242 @@
/**
* Document vault, Phase 3 — share a document through the clinician link, then
* serve it through the public token, end-to-end against a real Postgres.
*
* Covers the properties the unit mocks cannot pin:
* 1. Migration 0229: the `clinician_share_link_documents` join + its cascade.
* 2. Create-with-documents: own live doc frozen onto the link in one txn;
* a foreign / unknown id is refused at create (no link minted).
* 3. The public serve route: passphrase-gated, token-confined to the frozen
* set, serves the exact stored bytes with the class-correct posture; a
* foreign id and a revoked link both 404.
* 4. Deleting the document cascades its membership out of the link.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
process.env.API_TOKEN_HMAC_KEY ??=
"integration-share-link-hmac-key-at-least-32-chars";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n");
async function seedVaultUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
modulePreferencesJson: { inboundDocuments: true },
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
function uploadRequest(bytes: Buffer, filename: string): Request {
const payload = new Uint8Array(bytes.byteLength);
payload.set(bytes);
const formData = new FormData();
formData.append("file", new Blob([payload]), filename);
return new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
});
}
function jsonRequest(url: string, method: string, body: unknown): Request {
return new Request(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function routes() {
const upload = await import("@/app/api/documents/inbound/route");
const shareLinks = await import("@/app/api/share-links/route");
const unlock = await import("@/app/api/c/[token]/unlock/route");
const serve = await import("@/app/c/[token]/d/[id]/route");
return {
upload: upload.POST as unknown as (r: Request) => Promise<Response>,
createShare: shareLinks.POST as unknown as (
r: Request,
) => Promise<Response>,
unlock: unlock.POST as unknown as (
r: Request,
c: { params: Promise<{ token: string }> },
) => Promise<Response>,
serve: serve.GET as unknown as (
r: Request,
c: { params: Promise<{ token: string; id: string }> },
) => Promise<Response>,
};
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("share-link documents — end-to-end", () => {
it("attaches an own document, then serves it through the unlocked token", async () => {
await seedVaultUser("share-owner");
const prisma = getPrismaClient();
const { upload, createShare, unlock, serve } = await routes();
// Upload a document the owner will share.
const up = await upload(uploadRequest(PDF_SMALL, "befund.pdf"));
expect(up.status).toBe(201);
const docId = (await up.json()).data.id as string;
// Create a share link carrying exactly that document.
const created = await createShare(
jsonRequest("http://localhost/api/share-links", "POST", {
label: "Cardiology",
rangeStart: "2026-01-01T00:00:00Z",
rangeEnd: null,
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
documentIds: [docId],
}),
);
expect(created.status).toBe(201);
const share = (await created.json()).data as {
token: string;
passphrase: string;
documentCount: number;
};
expect(share.documentCount).toBe(1);
// The membership row exists in the DB (join table, migration 0229).
expect(await prisma.clinicianShareLinkDocument.count()).toBe(1);
const serveUrl = `http://localhost/c/${share.token}/d/${docId}`;
// Before unlock: the passphrase gate serves nothing.
const locked = await serve(new Request(serveUrl), {
params: Promise.resolve({ token: share.token, id: docId }),
});
expect(locked.status).toBe(404);
// Unlock with the passphrase → the token-scoped cookie is set.
const unlocked = await unlock(
jsonRequest(`http://localhost/api/c/${share.token}/unlock`, "POST", {
passphrase: share.passphrase,
}),
{ params: Promise.resolve({ token: share.token }) },
);
expect(unlocked.status).toBe(200);
// Now the blob serves: exact bytes, Class A inline posture.
const ok = await serve(new Request(serveUrl), {
params: Promise.resolve({ token: share.token, id: docId }),
});
expect(ok.status).toBe(200);
expect(ok.headers.get("Content-Type")).toBe("application/pdf");
expect(ok.headers.get("Content-Disposition")).toContain("inline");
expect(ok.headers.get("Cache-Control")).toBe("private, no-store");
expect(ok.headers.get("Content-Security-Policy")).toBeNull();
const served = Buffer.from(await ok.arrayBuffer());
expect(served.equals(PDF_SMALL)).toBe(true);
// A document id NOT on the frozen set is unreachable through the token.
const foreign = await serve(
new Request(`http://localhost/c/${share.token}/d/does-not-exist`),
{ params: Promise.resolve({ token: share.token, id: "does-not-exist" }) },
);
expect(foreign.status).toBe(404);
// Revoke the link → the blob serves nothing (revocation before decrypt).
await prisma.clinicianShareLink.updateMany({
data: { revokedAt: new Date() },
});
const revoked = await serve(new Request(serveUrl), {
params: Promise.resolve({ token: share.token, id: docId }),
});
expect(revoked.status).toBe(404);
});
it("refuses a share create referencing a document the caller does not own", async () => {
// Owner A uploads a document.
const _a = await seedVaultUser("share-a");
const { upload, createShare } = await routes();
const up = await upload(uploadRequest(PDF_SMALL, "a.pdf"));
const aDocId = (await up.json()).data.id as string;
// Owner B tries to attach A's document id to their own link.
cookieJar.clear();
await seedVaultUser("share-b");
const created = await createShare(
jsonRequest("http://localhost/api/share-links", "POST", {
label: "Steal",
rangeStart: "2026-01-01T00:00:00Z",
rangeEnd: null,
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
documentIds: [aDocId],
}),
);
expect(created.status).toBe(422);
// No link and no membership row were created.
expect(await getPrismaClient().clinicianShareLink.count()).toBe(0);
expect(await getPrismaClient().clinicianShareLinkDocument.count()).toBe(0);
});
it("drops a shared document out of the link when the owner deletes it (cascade)", async () => {
await seedVaultUser("share-cascade");
const prisma = getPrismaClient();
const { upload, createShare } = await routes();
const up = await upload(uploadRequest(PDF_SMALL, "c.pdf"));
const docId = (await up.json()).data.id as string;
await createShare(
jsonRequest("http://localhost/api/share-links", "POST", {
label: "Cascade",
rangeStart: "2026-01-01T00:00:00Z",
rangeEnd: null,
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
documentIds: [docId],
}),
);
expect(await prisma.clinicianShareLinkDocument.count()).toBe(1);
// Hard-delete the document → its membership cascades away cleanly.
await prisma.inboundDocument.delete({ where: { id: docId } });
expect(await prisma.clinicianShareLinkDocument.count()).toBe(0);
// The link itself survives (a share can lose all its documents).
expect(await prisma.clinicianShareLink.count()).toBe(1);
});
});