Merge branch 'feat/vault-p3-qa' into release/v1.27.24

This commit is contained in:
Marc-André Bombeck committed 2026-07-07 23:04:20 +02:00
commit 77f7caf4bf
30 files changed
+2595 -59

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
+93
View File
@@ -160,6 +160,30 @@ function tinyPng(marker: string): Buffer {
return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]);
}
/**
* A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment so the
* share serve route's egress EXIF strip is observable end-to-end: the stored
* bytes contain `GPSLatitude`, the served bytes must not. SOI → APP1(Exif) →
* SOS(+entropy). Not a decodable photo — the serve route sniffs the STORED
* mime type, never re-decodes pixels, so this is enough to exercise the strip.
*/
function jpegWithGps(marker: string): Buffer {
const payload = Buffer.concat([
Buffer.from("Exif\0\0"),
Buffer.from(`GPSLatitude=48.137 ${marker}`),
]);
const lenField = Buffer.alloc(2);
lenField.writeUInt16BE(payload.length + 2);
const app1 = Buffer.concat([Buffer.from([0xff, 0xe1]), lenField, 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]);
}
// ─── Fixture identity ───────────────────────────────────────────────────────
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
@@ -173,6 +197,21 @@ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
export const CONTENT_DOC_ID = "e2evaultcontent00000001";
/** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */
export const CONTENT_BODY_WORD = "pneumothorax";
/**
* v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and
* serves through the public `/c/<token>/d/<id>` route:
* - a PDF (Class A, inline preview via <iframe>),
* - a JPEG carrying an Exif GPS marker (Class A image, inline <img>; the
* serve route strips the marker on egress — the stored bytes keep it),
* - a text/plain document (Class B, download-only opaque attachment).
* Titles carry the `Share e2e` prefix so the owner picker's search isolates
* exactly this set. Idempotent (fixed ids upserted).
*/
export const SHARE_PDF_DOC_ID = "e2eshare0000000000000pdf";
export const SHARE_JPEG_DOC_ID = "e2eshare000000000000jpeg";
export const SHARE_TEXT_DOC_ID = "e2eshare000000000000text";
export const SHARE_DOC_PREFIX = "Share e2e";
/** Filing date of the MRT report — "last autumn" relative to the fixture. */
const MRT_DOC_DATE = "2025-10-14";
@@ -439,3 +478,57 @@ export async function seedNamespaceDocs(
await pool.end();
}
}
/**
* v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID`
* constants). Enables the vault module for the e2e user and upserts one
* inline-PDF, one Exif/GPS-bearing JPEG, and one download-only text document,
* all owned by the seeded account. Idempotent; safe from a spec `beforeAll`.
*/
export async function ensureShareDocFixture(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool);
await pool.query(
`UPDATE users
SET module_preferences_json =
COALESCE(module_preferences_json, '{}'::jsonb)
|| '{"inboundDocuments": true}'::jsonb
WHERE id = $1`,
[userId],
);
await upsertDocs(pool, userId, [
{
id: SHARE_PDF_DOC_ID,
kind: "DOCTOR_REPORT",
title: `${SHARE_DOC_PREFIX} report`,
filename: "share-report.pdf",
mimeType: "application/pdf",
documentDate: "2026-05-20",
bytes: tinyPdf(SHARE_PDF_DOC_ID),
},
{
id: SHARE_JPEG_DOC_ID,
kind: "IMAGING",
title: `${SHARE_DOC_PREFIX} scan`,
filename: "share-scan.jpg",
mimeType: "image/jpeg",
documentDate: "2026-05-21",
bytes: jpegWithGps(SHARE_JPEG_DOC_ID),
},
{
id: SHARE_TEXT_DOC_ID,
kind: "OTHER",
title: `${SHARE_DOC_PREFIX} notes`,
filename: "share-notes.txt",
mimeType: "text/plain",
documentDate: "2026-05-22",
bytes: Buffer.from(`share e2e class-B payload ${SHARE_TEXT_DOC_ID}`),
},
]);
} finally {
await pool.end();
}
}
+254
View File
@@ -0,0 +1,254 @@
/**
* v1.28 (document vault, Phase 3) — clinician document-sharing lifecycle e2e.
*
* Drives the whole public-surface path against the production server + a real
* Postgres (migrations 0228 + 0229): the owner picks documents into a new share
* link through the Settings picker, a recipient unlocks the link with the
* passphrase and pulls each blob through the public `/c/<token>/d/<id>` serve
* route, and a revoke closes it. The load-bearing assertions:
*
* - the serve route confines to the link's FROZEN set — an owned document NOT
* attached to the link 404s (token-scoped, never a global document fetch);
* - the serving posture matches the owner route — Class A (PDF/JPEG) inline
* with its true type + nosniff + no-store, Class B (text) opaque
* `application/octet-stream` attachment;
* - EXIF/GPS is stripped from the shared JPEG on egress (the served bytes
* drop the marker the stored bytes still carry);
* - revocation is enforced at serve time — a revoked link serves nothing;
* - the clinician view renders inline previews (img/iframe) for Class A and a
* download-only affordance for Class B;
* - the create reveal promotes a scannable QR (runs under the 390px mobile
* project too).
*
* The share fixtures (`ensureShareDocFixture`) seed the attached trio; the
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureShareDocFixture,
ensureVaultFixture,
SHARE_DOC_PREFIX,
SHARE_JPEG_DOC_ID,
SHARE_PDF_DOC_ID,
SHARE_TEXT_DOC_ID,
} from "./setup/vault-fixture";
const BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
/** An owned document from the broad corpus that is NEVER attached to the link
* under test — the foreign-id probe against the frozen-set confinement. */
const UNATTACHED_OWNED_DOC_ID = "e2evaultdoc000000000000";
interface CreatedShare {
token: string;
passphrase: string;
label: string;
}
/**
* Drive the owner create flow: open the picker, isolate the share-fixture trio
* by search, select all three, create the link, and read the one-time token +
* passphrase off the reveal card. Also asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(label);
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
const search = page.getByPlaceholder("Search documents");
await search.fill(SHARE_DOC_PREFIX);
const list = page.getByTestId("share-doc-picker-list");
await expect(list).toBeVisible();
// Exactly the three share-fixture documents match the prefix.
const rows = list.getByRole("button");
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i++) await rows.nth(i).click();
await page.getByRole("button", { name: "Done" }).click();
// Three chips staged before create.
await expect(
page.getByTestId("share-attached-chips").getByRole("listitem"),
).toHaveCount(3);
await page.getByRole("button", { name: "Create link" }).click();
const reveal = page.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
await expect(page.getByTestId("share-created-doc-count")).toContainText("3");
// QR promotion — present and rendered at a scannable size (the UI wave
// promoted it as the primary mobile affordance; runs under the 390px project).
const qrBlock = page.getByTestId("share-qr-block");
await expect(qrBlock).toBeVisible();
const qrImg = qrBlock.getByRole("img");
await expect(qrImg).toBeVisible();
const box = await qrImg.boundingBox();
expect(box, "QR image has a rendered box").not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(160);
// The one-time secrets: token from the share URL, passphrase from its cell.
const shareUrl = (await reveal.locator("code").first().innerText()).trim();
const token = shareUrl.split("/c/")[1];
expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/);
const passphrase = (
await page.getByTestId("share-passphrase").innerText()
).trim();
expect(passphrase.length).toBeGreaterThan(0);
return { token, passphrase, label };
}
/** A recipient browser context (no owner session) that has unlocked `token`
* by following the `#k=<passphrase>` deep link the QR encodes. */
async function openUnlockedClinician(
browser: Browser,
share: CreatedShare,
): Promise<Page> {
const ctx = await browser.newContext({
baseURL: BASE,
serviceWorkers: "block",
});
const page = await ctx.newPage();
// The gate reads `#k=` from the fragment, POSTs the unlock, and reloads with
// the record — exactly what a clinician scanning the QR would trigger.
await page.goto(`${BASE}/c/${share.token}#${"k"}=${share.passphrase}`);
await expect(
page.getByRole("heading", { name: "Shared health record" }),
).toBeVisible({ timeout: 15_000 });
return page;
}
test.describe("clinician document sharing", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureShareDocFixture();
});
test("share → unlock → serve (posture + EXIF strip) → revoke → 404", async ({
page,
browser,
}) => {
const share = await createShareWithDocs(page);
const clinician = await openUnlockedClinician(browser, share);
// ── The clinician view lists the documents with the right affordances ──
await expect(
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// Class A image → inline <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
).toHaveCount(1);
// Class B text → NO inline frame; the download-only hint is shown.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_TEXT_DOC_ID}"]`),
).toHaveCount(0);
await expect(
clinician.getByText("This file downloads to your device"),
).toBeVisible();
const serve = (id: string) => `${BASE}/c/${share.token}/d/${id}`;
// ── Class A PDF: inline, true type, nosniff, no-store ──
const pdf = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(pdf.status()).toBe(200);
expect(pdf.headers()["content-type"]).toBe("application/pdf");
expect(pdf.headers()["content-disposition"]).toContain("inline");
expect(pdf.headers()["x-content-type-options"]).toBe("nosniff");
// The proxy's `/c/` edge block wins over the route header (middleware
// headers override route headers), yielding a stricter no-store posture.
expect(pdf.headers()["cache-control"]).toContain("no-store");
// The proxy owns the CSP for this path (SAMEORIGIN framing + document CSP).
expect(pdf.headers()["content-security-policy"]).toContain(
"frame-ancestors 'self'",
);
expect(pdf.headers()["x-frame-options"]).toBe("SAMEORIGIN");
// ── Class A JPEG: inline image, EXIF/GPS stripped on egress ──
const jpeg = await clinician.request.get(serve(SHARE_JPEG_DOC_ID));
expect(jpeg.status()).toBe(200);
expect(jpeg.headers()["content-type"]).toBe("image/jpeg");
expect(jpeg.headers()["content-disposition"]).toContain("inline");
const jpegBody = await jpeg.body();
expect(
jpegBody.includes(Buffer.from("GPSLatitude")),
"served JPEG must not carry the stored GPS marker",
).toBe(false);
// ── Class B text: opaque octet-stream attachment (never inline) ──
const txt = await clinician.request.get(serve(SHARE_TEXT_DOC_ID));
expect(txt.status()).toBe(200);
expect(txt.headers()["content-type"]).toBe("application/octet-stream");
expect(txt.headers()["content-disposition"]).toContain("attachment");
expect(txt.headers()["x-content-type-options"]).toBe("nosniff");
// ── Frozen-set confinement: an OWNED but un-attached document 404s ──
const foreign = await clinician.request.get(serve(UNATTACHED_OWNED_DOC_ID));
expect(foreign.status()).toBe(404);
// A syntactically-plausible but unknown id also 404s (same blunt miss).
const guessed = await clinician.request.get(
serve("e2edoesnotexist00000001"),
);
expect(guessed.status()).toBe(404);
// ── Revoke through the owner UI, then the serve route stops cold ──
await page.reload();
const activeItem = page
.getByTestId("share-active-list")
.getByRole("listitem")
.filter({ hasText: share.label })
.first();
await expect(activeItem).toBeVisible();
await activeItem.getByRole("button", { name: "Revoke" }).click();
// Confirm in the alert dialog.
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Revoke" })
.click();
// The already-unlocked recipient can no longer pull any blob — revocation
// is re-enforced at serve time, before any decrypt.
await expect(async () => {
const afterRevoke = await clinician.request.get(serve(SHARE_PDF_DOC_ID));
expect(afterRevoke.status()).toBe(404);
}).toPass({ timeout: 10_000 });
await clinician.context().close();
});
test("picker enforces the document cap at the limit", async ({ page }) => {
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(`${SHARE_DOC_PREFIX} cap`);
await page.getByTestId("share-attach-open").click();
const list = page.getByTestId("share-doc-picker-list");
await expect(list).toBeVisible();
// The picker loads one page (server `limit=50`), which equals the cap; the
// corpus fixture guarantees a full page.
const rows = list.getByRole("button");
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(50);
// Select up to the cap. Each click below the cap is enabled; the click that
// reaches the cap flips `atCap`, after which the max-reached banner shows.
for (let i = 0; i < 50; i++) await rows.nth(i).click();
await expect(
page.getByText("You've reached the maximum of 50"),
).toBeVisible();
// The footer count is pinned at the cap ("50 of 50 selected").
await expect(page.getByText("50 of 50 selected").first()).toBeVisible();
});
});
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.",
"copyLink": "Link kopieren",
"copyPassphrase": "Passphrase kopieren",
"protected": "Passphrase"
"protected": "Passphrase",
"attachTitle": "Dokumente anhängen",
"attachButton": "Dokumente auswählen",
"attachCount": "{count} von {max} ausgewählt",
"attachFrozen": "Die Dokumentenauswahl ist nach dem Erstellen des Links fest — Dokumente lassen sich danach weder hinzufügen noch entfernen.",
"attachRemove": "{title} entfernen",
"exifNote": "Kamera-Metadaten (EXIF/GPS) werden beim Teilen aus JPEG-, PNG- und WebP-Bildern entfernt. PDF-, TIFF-, HEIC- und Office-Dateien werden unverändert übernommen.",
"documentCount": "Angehängte Dokumente: {count}",
"qrScanTitle": "Zum Öffnen auf einem anderen Gerät scannen",
"qrScanHint": "Richte eine Handykamera auf den Code, um die geteilte Akte zu öffnen — die Passphrase ist bereits enthalten.",
"pickerTitle": "Dokumente anhängen",
"pickerDescription": "Durchsuche deinen Tresor und wähle Dokumente zum Teilen. Bis zu {max}.",
"pickerSearch": "Dokumente suchen",
"pickerEmpty": "Keine Dokumente gefunden.",
"pickerDone": "Fertig",
"pickerSelectedCount": "{count} von {max} ausgewählt",
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
},
"genderOther": "Divers",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "Diese geteilte Ansicht konnte nicht geladen werden",
"description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.",
"retry": "Erneut versuchen"
},
"documents": {
"title": "Dokumente",
"untitled": "Unbenanntes Dokument",
"download": "Herunterladen",
"attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).",
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
}
},
"cycle": {
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.",
"copyLink": "Copy link",
"copyPassphrase": "Copy passphrase",
"protected": "Passphrase"
"protected": "Passphrase",
"attachTitle": "Attach documents",
"attachButton": "Choose documents",
"attachCount": "{count} of {max} selected",
"attachFrozen": "The document set is fixed once you create the link — documents can't be added or removed afterwards.",
"attachRemove": "Remove {title}",
"exifNote": "Camera metadata (EXIF/GPS) is removed from JPEG, PNG and WebP images when shared. PDF, TIFF, HEIC and Office files pass through unchanged.",
"documentCount": "Attached documents: {count}",
"qrScanTitle": "Scan to open on another device",
"qrScanHint": "Point a phone camera at the code to open the shared record — the passphrase is already included.",
"pickerTitle": "Attach documents",
"pickerDescription": "Search your vault and select documents to share. Up to {max}.",
"pickerSearch": "Search documents",
"pickerEmpty": "No documents found.",
"pickerDone": "Done",
"pickerSelectedCount": "{count} of {max} selected",
"pickerMaxReached": "You've reached the maximum of {max} documents."
},
"genderOther": "Other",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "This shared view could not be loaded",
"description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.",
"retry": "Try again"
},
"documents": {
"title": "Documents",
"untitled": "Untitled document",
"download": "Download",
"attachmentHint": "This file downloads to your device (no in-page preview).",
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
}
},
"cycle": {
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.",
"copyLink": "Copiar enlace",
"copyPassphrase": "Copiar frase de acceso",
"protected": "Frase de acceso"
"protected": "Frase de acceso",
"attachTitle": "Adjuntar documentos",
"attachButton": "Elegir documentos",
"attachCount": "{count} de {max} seleccionados",
"attachFrozen": "El conjunto de documentos queda fijo al crear el enlace: después no se pueden añadir ni quitar documentos.",
"attachRemove": "Quitar {title}",
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se eliminan de las imágenes JPEG, PNG y WebP al compartir. Los archivos PDF, TIFF, HEIC y Office se comparten sin cambios.",
"documentCount": "Documentos adjuntos: {count}",
"qrScanTitle": "Escanéalo para abrirlo en otro dispositivo",
"qrScanHint": "Apunta la cámara del teléfono al código para abrir el registro compartido: la frase de acceso ya está incluida.",
"pickerTitle": "Adjuntar documentos",
"pickerDescription": "Busca en tu archivo y selecciona documentos para compartir. Hasta {max}.",
"pickerSearch": "Buscar documentos",
"pickerEmpty": "No se encontraron documentos.",
"pickerDone": "Listo",
"pickerSelectedCount": "{count} de {max} seleccionados",
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
},
"genderOther": "Otro",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "No se pudo cargar esta vista compartida",
"description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.",
"retry": "Intentar de nuevo"
},
"documents": {
"title": "Documentos",
"untitled": "Documento sin título",
"download": "Descargar",
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
}
},
"cycle": {
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.",
"copyLink": "Copier le lien",
"copyPassphrase": "Copier la phrase secrète",
"protected": "Phrase secrète"
"protected": "Phrase secrète",
"attachTitle": "Joindre des documents",
"attachButton": "Choisir des documents",
"attachCount": "{count} sur {max} sélectionnés",
"attachFrozen": "La sélection de documents est figée à la création du lien — impossible d'ajouter ou de retirer des documents ensuite.",
"attachRemove": "Retirer {title}",
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) sont supprimées des images JPEG, PNG et WebP lors du partage. Les fichiers PDF, TIFF, HEIC et Office sont transmis tels quels.",
"documentCount": "Documents joints : {count}",
"qrScanTitle": "Scannez pour ouvrir sur un autre appareil",
"qrScanHint": "Pointez l'appareil photo d'un téléphone vers le code pour ouvrir le dossier partagé — la phrase secrète est déjà incluse.",
"pickerTitle": "Joindre des documents",
"pickerDescription": "Recherchez dans votre coffre et sélectionnez les documents à partager. Jusqu'à {max}.",
"pickerSearch": "Rechercher des documents",
"pickerEmpty": "Aucun document trouvé.",
"pickerDone": "Terminé",
"pickerSelectedCount": "{count} sur {max} sélectionnés",
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
},
"genderOther": "Autre",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "Impossible de charger cette vue partagée",
"description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.",
"retry": "Réessayer"
},
"documents": {
"title": "Documents",
"untitled": "Document sans titre",
"download": "Télécharger",
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
}
},
"cycle": {
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.",
"copyLink": "Copia link",
"copyPassphrase": "Copia passphrase",
"protected": "Passphrase"
"protected": "Passphrase",
"attachTitle": "Allega documenti",
"attachButton": "Scegli documenti",
"attachCount": "{count} di {max} selezionati",
"attachFrozen": "La selezione dei documenti è fissata alla creazione del link: dopo non è possibile aggiungere o rimuovere documenti.",
"attachRemove": "Rimuovi {title}",
"exifNote": "I metadati della fotocamera (EXIF/GPS) vengono rimossi dalle immagini JPEG, PNG e WebP durante la condivisione. I file PDF, TIFF, HEIC e Office vengono condivisi invariati.",
"documentCount": "Documenti allegati: {count}",
"qrScanTitle": "Scansiona per aprire su un altro dispositivo",
"qrScanHint": "Inquadra il codice con la fotocamera del telefono per aprire la cartella condivisa: la passphrase è già inclusa.",
"pickerTitle": "Allega documenti",
"pickerDescription": "Cerca nel tuo archivio e seleziona i documenti da condividere. Fino a {max}.",
"pickerSearch": "Cerca documenti",
"pickerEmpty": "Nessun documento trovato.",
"pickerDone": "Fatto",
"pickerSelectedCount": "{count} di {max} selezionati",
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
},
"genderOther": "Altro",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "Impossibile caricare questa vista condivisa",
"description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.",
"retry": "Riprova"
},
"documents": {
"title": "Documenti",
"untitled": "Documento senza titolo",
"download": "Scarica",
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
}
},
"cycle": {
+24 -1
View File
@@ -5123,7 +5123,23 @@
"passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.",
"copyLink": "Kopiuj link",
"copyPassphrase": "Kopiuj hasło",
"protected": "Hasło"
"protected": "Hasło",
"attachTitle": "Dołącz dokumenty",
"attachButton": "Wybierz dokumenty",
"attachCount": "Wybrano {count} z {max}",
"attachFrozen": "Zestaw dokumentów jest ustalany przy tworzeniu linku — później nie można dodawać ani usuwać dokumentów.",
"attachRemove": "Usuń {title}",
"exifNote": "Metadane aparatu (EXIF/GPS) są usuwane z obrazów JPEG, PNG i WebP podczas udostępniania. Pliki PDF, TIFF, HEIC i Office są przekazywane bez zmian.",
"documentCount": "Dołączone dokumenty: {count}",
"qrScanTitle": "Zeskanuj, aby otworzyć na innym urządzeniu",
"qrScanHint": "Skieruj aparat telefonu na kod, aby otworzyć udostępnioną dokumentację — hasło jest już dołączone.",
"pickerTitle": "Dołącz dokumenty",
"pickerDescription": "Przeszukaj swój sejf i wybierz dokumenty do udostępnienia. Maksymalnie {max}.",
"pickerSearch": "Szukaj dokumentów",
"pickerEmpty": "Nie znaleziono dokumentów.",
"pickerDone": "Gotowe",
"pickerSelectedCount": "Wybrano {count} z {max}",
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
},
"genderOther": "Inna",
"cycleTracking": {
@@ -6595,6 +6611,13 @@
"title": "Nie udało się załadować udostępnionego widoku",
"description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.",
"retry": "Spróbuj ponownie"
},
"documents": {
"title": "Dokumenty",
"untitled": "Dokument bez tytułu",
"download": "Pobierz",
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
}
},
"cycle": {
@@ -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.
+4 -1
View File
@@ -103,7 +103,7 @@ export default async function ClinicianSharePage({
const context = await resolveShareToken(token);
if (!context) notFound();
const [{ report, sections }, locale] = await Promise.all([
const [{ report, sections, documents }, locale] = await Promise.all([
loadShareViewData(context),
resolveLocale(),
]);
@@ -116,6 +116,9 @@ export default async function ClinicianSharePage({
expiresAt={context.expiresAt.toISOString()}
report={report}
sections={sections}
documents={documents}
token={token}
locale={locale}
/>
);
}
@@ -5,6 +5,7 @@
* carries the descriptive "not a clinical assessment / not a diagnosis"
* disclaimer, the provenance header renders, and the view holds no app chrome.
*/
import type React from "react";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -59,7 +60,15 @@ function makeReport(
} as DoctorReportData;
}
function render(report: DoctorReportData) {
function render(
report: DoctorReportData,
extra?: Partial<
Pick<
React.ComponentProps<typeof ClinicianView>,
"documents" | "token" | "locale"
>
>,
) {
const { t } = getServerTranslator("en");
return renderToStaticMarkup(
<ClinicianView
@@ -68,6 +77,7 @@ function render(report: DoctorReportData) {
expiresAt="2026-03-01T00:00:00.000Z"
report={report}
sections={{ ...DEFAULT_DOCTOR_REPORT_PREFS }}
{...extra}
/>,
);
}
@@ -107,4 +117,70 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain('data-slot="sidebar"');
expect(html).not.toContain("coach");
});
it("renders no document section when the frozen set is empty", () => {
const html = render(makeReport(), { documents: [], token: "hls_abc" });
expect(html).not.toContain("Documents</h2>");
});
it("renders Class A inline: image → <img>, PDF → <iframe>, both at the serve route", () => {
const html = render(makeReport(), {
token: "hls_tok",
documents: [
{
id: "img-1",
title: "Skin photo",
kind: "IMAGING",
documentDate: "2026-01-10",
byteSize: 20480,
mimeType: "image/jpeg",
servingClass: "inline",
},
{
id: "pdf-1",
title: "Blood panel",
kind: "LAB_RESULT",
documentDate: "2026-01-12",
byteSize: 51200,
mimeType: "application/pdf",
servingClass: "inline",
},
],
});
// Section header present.
expect(html).toContain("Documents");
// Image → <img> pointed at the token-scoped serve route.
expect(html).toContain('src="/c/hls_tok/d/img-1"');
expect(html).toContain("<img");
// PDF → <iframe> pointed at the same route family.
expect(html).toContain('src="/c/hls_tok/d/pdf-1"');
expect(html).toContain("<iframe");
// Titles render as escaped text.
expect(html).toContain("Skin photo");
expect(html).toContain("Blood panel");
});
it("renders Class B as a download link with no inline preview frame", () => {
const html = render(makeReport(), {
token: "hls_tok",
documents: [
{
id: "doc-b",
title: "Referral letter",
kind: "REFERRAL",
documentDate: null,
byteSize: 8192,
mimeType: "application/msword",
servingClass: "attachment",
},
],
});
// A download anchor at the serve route.
expect(html).toContain('href="/c/hls_tok/d/doc-b"');
expect(html).toContain("Download");
// No inline preview for an attachment-class document.
expect(html).not.toContain("<iframe");
expect(html).not.toContain('<img src="/c/hls_tok/d/doc-b"');
expect(html).toContain("Referral letter");
});
});
+125
View File
@@ -10,8 +10,14 @@
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
* clinical assessment / not a diagnosis" disclaimer.
*/
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
type Translate = (
@@ -27,6 +33,16 @@ interface ClinicianViewProps {
expiresAt: string;
report: DoctorReportData;
sections: DoctorReportPrefs;
/**
* The frozen document set on this link (metadata only — never bytes). Each
* entry points at the token-scoped serve route (`/c/<token>/d/<id>`), the
* one decrypt path. Empty when the owner shared no documents.
*/
documents?: ShareViewDocument[];
/** The raw share token from the path — used to build serve-route URLs. */
token?: string;
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: string;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
@@ -74,12 +90,101 @@ function Section({
);
}
/**
* One shared document. Class A (inline) renders a preview straight at the
* token-scoped serve route — an `<img>` for raster images, a framed PDF
* otherwise; the proxy's share-serve CSP (`default-src 'none';
* frame-ancestors 'self'`) fences the framed context. Class B (attachment)
* offers a plain download link — the serve route answers it as an opaque
* `application/octet-stream` attachment. No bytes reach this component; only
* the metadata and the URL.
*/
function DocumentEntry({
t,
doc,
token,
locale,
}: {
t: Translate;
doc: ShareViewDocument;
token: string;
locale: string;
}) {
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
const title = doc.title ?? t("clinicianView.documents.untitled");
const Icon =
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
Boolean,
) as string[];
const isImage = doc.mimeType.startsWith("image/");
const isInline = doc.servingClass === "inline";
return (
<li className="border-border bg-card rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
{Icon ? (
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
) : null}
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
{meta.length > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{meta.join(" · ")}
</p>
) : null}
</div>
</div>
{/* Download is always available (inline or attachment) so the recipient
can keep a copy; the anchor hits the same token-scoped serve route. */}
<a
href={serveUrl}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
<Download className="size-3.5" aria-hidden />
{t("clinicianView.documents.download")}
</a>
</div>
{isInline ? (
<div className="mt-3">
{isImage ? (
// The served image is the frozen original with EXIF/GPS stripped on
// egress; it renders inline within the Class A carve-out.
// eslint-disable-next-line @next/next/no-img-element
<img
src={serveUrl}
alt={title}
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
/>
) : (
<iframe
src={serveUrl}
title={title}
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
/>
)}
</div>
) : (
<p className="text-muted-foreground mt-2 text-xs">
{t("clinicianView.documents.attachmentHint")}
</p>
)}
</li>
);
}
export function ClinicianView({
t,
label,
expiresAt,
report,
sections,
documents = [],
token = "",
locale = "en",
}: ClinicianViewProps) {
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
const fmtNum = (n: number) => Math.round(n * 100) / 100;
@@ -230,6 +335,26 @@ export function ClinicianView({
</div>
</section>
) : null}
{/* ── Shared documents ────────────────────────────────────── */}
{documents.length > 0 && token ? (
<Section title={t("clinicianView.documents.title")}>
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.documents.exifNote")}
</p>
<ul className="space-y-3">
{documents.map((doc) => (
<DocumentEntry
key={doc.id}
t={t}
doc={doc}
token={token}
locale={locale}
/>
))}
</ul>
</Section>
) : null}
</div>
<footer className="border-border text-muted-foreground mt-8 border-t pt-4 text-center text-xs">
@@ -35,6 +35,7 @@ const FIXTURE_LINKS = [
rangeEnd: null,
resourceTypes: ["Patient", "Observation"],
allowFhirApi: true,
documentCount: 2,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-06-01T00:00:00.000Z",
revokedAt: null,
@@ -49,6 +50,7 @@ const FIXTURE_LINKS = [
rangeEnd: null,
resourceTypes: [],
allowFhirApi: false,
documentCount: 0,
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-05-01T00:00:00.000Z",
revokedAt: "2026-05-15T00:00:00.000Z",
@@ -122,4 +124,28 @@ describe("<SharingSection> — owner share-link surface (C7)", () => {
const html = render("de");
expect(html).toContain("Neuer Freigabe-Link");
});
it("offers the document picker trigger and surfaces the ≤50 cap", () => {
const html = render();
// The attach-documents affordance is present on the create form.
expect(html).toContain('data-testid="share-attach-open"');
expect(html).toContain("Attach documents");
// The cap the picker enforces client-side is surfaced in the count copy.
expect(html).toContain("of 50 selected");
});
it("surfaces the frozen-set warning so the write-once contract is explicit", () => {
const html = render();
// The set cannot be edited after creation — the UI must say so.
expect(html).toContain("fixed once you create the link");
});
it("shows a document-count badge on a link that carries documents", () => {
const html = render();
expect(html).toContain('data-testid="share-doc-count-badge"');
// The active fixture carries two documents.
const activeStart = html.indexOf('data-testid="share-active-list"');
const activeSlice = html.slice(activeStart);
expect(activeSlice).toContain('data-testid="share-doc-count-badge"');
});
});
@@ -0,0 +1,215 @@
"use client";
/**
* Owner document picker for the clinician-share create flow. A
* `ResponsiveSheet` (bottom sheet on phones) with a debounced search over the
* user's own vault — the SAME recall path as the vault page — and one
* multi-select toggle row per document. Selection is client-only state; the
* chosen ids POST with the share link, which freezes them write-once. The set
* is bounded at `max` (mirrors `SHARE_LINK_MAX_DOCUMENTS`): once the cap is
* reached, unselected rows disable so the client never posts an oversized set.
*
* Reuses the vault list endpoint + the `inboundDocumentPicker` query key from
* the central factory; reads unwrap `(await res.json()).data` via `apiGet`.
* No write happens here — attaching is the create mutation's job.
*/
import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { documentDateKey } from "@/components/documents/vault-utils";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
/** A picked document, carried by id + a display title (for the owner chips). */
export interface PickedDocument {
id: string;
title: string;
}
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function ShareDocumentPicker({
open,
onOpenChange,
selected,
onSelectedChange,
max,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
selected: PickedDocument[];
onSelectedChange: (next: PickedDocument[]) => void;
max: number;
}) {
const { t } = useTranslations();
const format = useFormatters();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
// 200 ms debounce, matching the vault's search feel.
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
};
const list = useQuery({
queryKey: queryKeys.inboundDocumentPicker(query),
enabled: open,
queryFn: () => {
const sp = new URLSearchParams();
if (query) sp.set("q", query);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", "50");
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const selectedIds = new Set(selected.map((s) => s.id));
const atCap = selected.length >= max;
function toggle(doc: InboundDocumentDto, title: string) {
if (selectedIds.has(doc.id)) {
onSelectedChange(selected.filter((s) => s.id !== doc.id));
return;
}
if (atCap) return;
onSelectedChange([...selected, { id: doc.id, title }]);
}
const documents = list.data?.documents ?? [];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("settings.sharing.pickerTitle")}
description={t("settings.sharing.pickerDescription", { max })}
footer={
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-xs" aria-live="polite">
{t("settings.sharing.pickerSelectedCount", {
count: selected.length,
max,
})}
</span>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("settings.sharing.pickerDone")}
</Button>
</div>
}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("settings.sharing.pickerSearch")}
aria-label={t("settings.sharing.pickerSearch")}
className="pl-9"
/>
</div>
{atCap ? (
<p
className="text-warning bg-warning/10 rounded-md px-3 py-2 text-xs"
role="status"
>
{t("settings.sharing.pickerMaxReached", { max })}
</p>
) : null}
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("settings.sharing.pickerEmpty")}
</p>
) : (
<ul
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
data-testid="share-doc-picker-list"
>
{documents.map((doc) => {
const picked = selectedIds.has(doc.id);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
const disabled = !picked && atCap;
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={picked}
disabled={disabled}
onClick={() => toggle(doc, title)}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
picked && "border-primary/40 bg-primary/5",
disabled && "cursor-not-allowed opacity-50",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
picked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
);
}
+157 -10
View File
@@ -23,7 +23,16 @@
*/
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react";
import {
Copy,
FileText,
KeyRound,
Loader2,
ScanLine,
Share2,
Trash2,
X,
} from "lucide-react";
import {
AlertDialog,
@@ -44,6 +53,10 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
import { useAuth } from "@/hooks/use-auth";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -63,6 +76,14 @@ type ResourceType = (typeof RESOURCE_TYPES)[number];
const MAX_DAYS = 90;
const DEFAULT_DAYS = 30;
/**
* Maximum documents per share — mirrors `SHARE_LINK_MAX_DOCUMENTS` on the
* server. Kept as a local literal (not imported from the validations module)
* because that module pulls the Prisma client into scope and would drag the DB
* into the client bundle; the server re-enforces the cap on create regardless.
*/
const MAX_DOCUMENTS = 50;
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
interface ShareLinkSummary {
id: string;
@@ -73,6 +94,8 @@ interface ShareLinkSummary {
allowFhirApi: boolean;
/** v1.18.7 — whether a passphrase second factor guards this link. */
protected: boolean;
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
documentCount: number;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
@@ -120,6 +143,8 @@ function ShareLinksCard() {
"Patient",
"Observation",
]);
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
@@ -140,7 +165,9 @@ function ShareLinksCard() {
// needs to ship eagerly.
import("qrcode")
.then(({ default: QRCode }) =>
QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }),
// Render at a high pixel density (the display size is capped in CSS) so
// the code stays crisp when scaled up for on-the-spot phone scanning.
QRCode.toDataURL(qrUrl, { margin: 2, width: 512 }),
)
.then((url) => {
if (!cancelled) setQrDataUrl(url);
@@ -177,6 +204,13 @@ function ShareLinksCard() {
rangeEnd: null,
resourceTypes,
allowFhirApi,
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
// entirely when nothing is attached (a documents-less share stays the
// default). The server re-validates each id as the caller's own live
// document before minting the link.
...(selectedDocs.length > 0
? { documentIds: selectedDocs.map((d) => d.id) }
: {}),
expiresAt: isoDaysFromNow(expiryDays),
});
},
@@ -186,6 +220,9 @@ function ShareLinksCard() {
setCreated(result);
setCopied(null);
setLabel("");
// The document set is frozen onto the link now — reset the picker so the
// next link starts clean.
setSelectedDocs([]);
setFormError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
},
@@ -333,6 +370,72 @@ function ShareLinksCard() {
</fieldset>
)}
{/* ── Attach documents ─────────────────────────────────────── */}
<div className="border-border space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("settings.sharing.attachTitle")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachCount", {
count: selectedDocs.length,
max: MAX_DOCUMENTS,
})}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setPickerOpen(true)}
data-testid="share-attach-open"
>
<FileText className="mr-1.5 h-3.5 w-3.5" />
{t("settings.sharing.attachButton")}
</Button>
</div>
{selectedDocs.length > 0 ? (
<ul
className="flex flex-wrap gap-1.5"
data-testid="share-attached-chips"
>
{selectedDocs.map((doc) => (
<li key={doc.id}>
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
<span className="max-w-[12rem] truncate">{doc.title}</span>
<button
type="button"
onClick={() =>
setSelectedDocs((prev) =>
prev.filter((d) => d.id !== doc.id),
)
}
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={t("settings.sharing.attachRemove", {
title: doc.title,
})}
>
<X className="size-3" aria-hidden />
</button>
</span>
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachFrozen")}
</p>
{selectedDocs.length > 0 ? (
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.exifNote")}
</p>
) : null}
</div>
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
@@ -351,6 +454,14 @@ function ShareLinksCard() {
</Button>
</form>
<ShareDocumentPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
selected={selectedDocs}
onSelectedChange={setSelectedDocs}
max={MAX_DOCUMENTS}
/>
{created && (
<div
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
@@ -359,26 +470,49 @@ function ShareLinksCard() {
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
{created.documentCount > 0 && (
<p
className="text-foreground text-xs font-medium"
data-testid="share-created-doc-count"
>
{t("settings.sharing.documentCount", {
count: created.documentCount,
})}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.shownOnce")}
</p>
{/* QR — carries the passphrase in the URL fragment, so scanning it
opens the record. Shown exactly once with the passphrase text. */}
opens the record (and its shared documents). Promoted as the
primary mobile affordance: a clinician scans it on the spot to
open the record on their own device. Shown exactly once. The
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
so the code stays scannable in the dark theme too. */}
{qrDataUrl && (
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.qrLabel")}
</p>
<div
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
data-testid="share-qr-block"
>
<div className="flex items-center gap-1.5">
<ScanLine className="text-foreground size-4" aria-hidden />
<p className="text-foreground text-sm font-medium">
{t("settings.sharing.qrScanTitle")}
</p>
</div>
{/* The QR is a transient secret render, not stored content. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrDataUrl}
alt={t("settings.sharing.qrAlt")}
width={220}
height={220}
className="bg-background rounded border p-2"
width={512}
height={512}
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
/>
<p className="text-muted-foreground max-w-xs text-xs">
{t("settings.sharing.qrScanHint")}
</p>
</div>
)}
@@ -481,6 +615,19 @@ function ShareLinksCard() {
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-[10px]"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
FHIR
@@ -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,65 @@ 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,
mimeType: "application/pdf",
servingClass: "inline",
},
{
id: "doc-b",
title: null,
kind: "OTHER",
documentDate: null,
byteSize: 6789,
mimeType: "application/msword",
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]);
}
+74 -3
View File
@@ -12,19 +12,47 @@
* 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;
/**
* The stored content type (metadata, never bytes). The recipient view uses
* it to pick the inline surface — an image tag for `image/*`, a framed PDF
* for `application/pdf` — within the Class A carve-out. Class B types are
* download-only regardless.
*/
mimeType: string;
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 +81,52 @@ 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,
mimeType: document.mimeType,
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.
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);
});
});