From d4965033c2bec903567b60de94f260a51ef08087 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 10 Jul 2026 00:18:57 +0200
Subject: [PATCH] fix(clinician-share): a document share exposes only the
document
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sharing a single document from the vault minted a full-record share: the
create form always defaulted `resourceTypes` to ["Patient","Observation"]
and never signalled a documents-only intent, so the served /c/ view
aggregated the whole doctor report — every vital, lab, medication and
wellness figure — alongside the attached file. An empty section blob was
indistinguishable from "defaults", so the report always resolved to the
full record.
Introduce an explicit documents-only share:
- add a `documentOnly` flag to the create payload; when set the server
forces an empty report scope (all-off sections, no FHIR resource types,
FHIR API off) and requires at least one document, so a document link can
never widen into a record — whatever the body sends.
- gate the clinician-view aggregation on an enabled section: a share with
no section on never calls the report aggregator, so no health metric
leaves the database. The recipient sees only the attached document(s).
- the document detail Share action opens the create form in documents-only
mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the
history-range selector, keep the picker/label/expiry/passphrase, and
always carry the launched document into the created link.
- Settings sharing keeps the full record-share form unchanged.
Extract the create-payload builder as a pure function and cover the scope
contract, the empty-scope data load, and the served-view metric-free
guarantee with tests; refresh the share e2e and regenerate the OpenAPI
contract.
---
docs/api/openapi.yaml | 2 +
e2e/share-documents.spec.ts | 57 +++++-
.../api/share-links/__tests__/route.test.ts | 45 +++++
src/app/api/share-links/route.ts | 21 ++-
src/app/c/[token]/page.tsx | 7 +-
src/components/clinician/clinician-view.tsx | 67 ++++---
.../documents/document-share-sheet.tsx | 1 +
.../__tests__/share-link-create-form.test.tsx | 76 +++++++-
.../settings/share-link-create-form.tsx | 168 +++++++++++++-----
.../__tests__/share-view-data.test.ts | 72 +++++++-
src/lib/clinician-share/share-view-data.ts | 31 +++-
src/lib/validations/clinician-share-link.ts | 16 +-
src/lib/validations/doctor-report-prefs.ts | 32 ++++
13 files changed, 503 insertions(+), 92 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 9fcb1df37..9c922c3e0 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1106,6 +1106,8 @@ paths:
type: string
minLength: 1
maxLength: 64
+ documentOnly:
+ type: boolean
expiresAt:
type: string
format: date-time
diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts
index 518853a56..1163bd826 100644
--- a/e2e/share-documents.spec.ts
+++ b/e2e/share-documents.spec.ts
@@ -234,12 +234,14 @@ test.describe("clinician document sharing", () => {
await clinician.context().close();
});
- test("the document detail Share action opens the create flow with that document pre-attached", async ({
+ test("the document Share action mints a DOCUMENT-ONLY link — the document, never the record", async ({
page,
+ browser,
}) => {
- // Opens two stacked sheets (detail → share) and drives a real create with
- // the one-time QR render — heavier than the default 30s budget on a loaded
- // runner. Runs on the desktop and 390px mobile projects.
+ // Opens two stacked sheets (detail → share), drives a real create with the
+ // one-time QR render, then unlocks the link as a recipient to prove the
+ // served view carries the document and ZERO health metrics. Heavier than
+ // the default 30s budget. Runs on the desktop and 390px mobile projects.
test.slow();
// Deep-link straight to the document detail sheet.
@@ -263,6 +265,13 @@ test.describe("clinician document sharing", () => {
await expect(chips).toHaveCount(1);
await expect(chips.first()).toContainText("MRT Knie");
+ // Document-only mode hides every record-scope control: no FHIR API toggle,
+ // no FHIR resource-type checkboxes, no history-range selector. Only the
+ // document, label, and expiry remain.
+ await expect(shareSheet.locator("#share-fhir")).toHaveCount(0);
+ await expect(shareSheet.locator("#share-range")).toHaveCount(0);
+ await expect(shareSheet.locator("#share-expiry")).toHaveCount(1);
+
// Create the link — the one-time reveal (with the scannable QR) lands in
// the same sheet, carrying the one attached document.
await shareSheet.getByRole("button", { name: "Create link" }).click();
@@ -276,6 +285,46 @@ test.describe("clinician document sharing", () => {
const box = await qrImg.boundingBox();
expect(box, "QR image has a rendered box").not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(160);
+
+ // Read the one-time secrets off the reveal and unlock the link as a
+ // recipient (no owner session).
+ 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 shareSheet.getByTestId("share-passphrase").innerText()
+ ).trim();
+ const clinician = await openUnlockedClinician(browser, {
+ token,
+ passphrase,
+ label: "MRT Knie",
+ });
+
+ // The served view lists the attached document…
+ await expect(
+ clinician.getByRole("heading", { name: "Documents" }),
+ ).toBeVisible();
+ await expect(
+ clinician.locator(
+ `iframe[src*="/d/${MRT_DOC_ID}"], img[src*="/d/${MRT_DOC_ID}"]`,
+ ),
+ ).toHaveCount(1);
+
+ // …and NOTHING from the health record: no vitals, no labs, no medications,
+ // no reporting-period line. This is the privacy guarantee — sharing a
+ // document must never expose a single metric.
+ await expect(
+ clinician.getByRole("heading", { name: "Vital signs" }),
+ ).toHaveCount(0);
+ await expect(
+ clinician.getByRole("heading", { name: "Lab values" }),
+ ).toHaveCount(0);
+ await expect(
+ clinician.getByRole("heading", { name: "Medications & adherence" }),
+ ).toHaveCount(0);
+ await expect(clinician.getByText("Reporting period:")).toHaveCount(0);
+
+ await clinician.context().close();
});
test("picker enforces the document cap at the limit", async ({ page }) => {
diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts
index 2398adafd..3da5d5ebe 100644
--- a/src/app/api/share-links/__tests__/route.test.ts
+++ b/src/app/api/share-links/__tests__/route.test.ts
@@ -41,6 +41,7 @@ vi.mock("next/headers", () => ({
import { POST, GET } from "../route";
import { DELETE } from "../[id]/route";
+import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -260,6 +261,50 @@ describe("POST /api/share-links — create", () => {
const body = (await res.json()) as { data: { documentCount: number } };
expect(body.data.documentCount).toBe(1);
});
+
+ it("documentOnly FORCES an empty report scope even when the body smuggles one", 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,
+ );
+
+ // A hostile / stale client tries to widen a document link into a record
+ // share: FHIR on, resource types set, sections requested. The server must
+ // ignore every one of them.
+ const res = await POST(
+ postReq(
+ validBody({
+ documentOnly: true,
+ allowFhirApi: true,
+ resourceTypes: ["Observation", "Patient"],
+ sections: { vitals: { bp: true }, labs: true },
+ documentIds: ["doc-a"],
+ }),
+ ),
+ );
+ expect(res.status).toBe(201);
+
+ const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
+ .calls[0][0];
+ // Empty FHIR scope, FHIR API off — no health surface at all.
+ expect(createArg.data.resourceTypes).toEqual([]);
+ expect(createArg.data.allowFhirApi).toBe(false);
+ // Sections persist as an explicit all-OFF blob (distinct from `{}` = full
+ // record defaults). The clinician view reads this as "no report".
+ expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS);
+ // The picked document still rides along.
+ expect(createArg.data.documents).toEqual({
+ create: [{ documentId: "doc-a" }],
+ });
+ });
+
+ it("rejects a documentOnly share with no documents (422)", async () => {
+ const res = await POST(postReq(validBody({ documentOnly: true })));
+ expect(res.status).toBe(422);
+ expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
+ });
});
describe("GET /api/share-links — list", () => {
diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts
index 94111c12a..82b32bd2a 100644
--- a/src/app/api/share-links/route.ts
+++ b/src/app/api/share-links/route.ts
@@ -29,6 +29,7 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
+import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import {
generatePassphrase,
hashPassphrase,
@@ -134,6 +135,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const input = parsed.data;
+ // v1.28.13 — a documents-only share carries NO report scope. Force the scope
+ // columns to empty here (not just at the client) so a document link can never
+ // serve a single health metric, whatever the body tried to smuggle: no
+ // sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
+ // "full record defaults"), no FHIR resource types, FHIR API off. "Share this
+ // document" means the document, not the whole record.
+ const documentOnly = input.documentOnly === true;
+ const sectionsJson = documentOnly
+ ? EMPTY_DOCTOR_REPORT_PREFS
+ : (input.sections ?? {});
+ const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
+ const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
+ annotate({ meta: { documentOnly } });
+
// Mint the 192-bit raw token, store only its HMAC hash.
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
const tokenHash = hashToken(rawToken);
@@ -175,9 +190,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
label: input.label,
rangeStart: new Date(input.rangeStart),
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
- sectionsJson: (input.sections ?? {}) as Prisma.InputJsonValue,
- resourceTypes: input.resourceTypes ?? [],
- allowFhirApi: input.allowFhirApi ?? false,
+ sectionsJson: sectionsJson as Prisma.InputJsonValue,
+ resourceTypes,
+ allowFhirApi,
expiresAt: new Date(input.expiresAt),
documents:
documentIds.length > 0
diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx
index 8900ba51f..6ab0d7554 100644
--- a/src/app/c/[token]/page.tsx
+++ b/src/app/c/[token]/page.tsx
@@ -103,10 +103,8 @@ export default async function ClinicianSharePage({
const context = await resolveShareToken(token);
if (!context) notFound();
- const [{ report, sections, documents }, locale] = await Promise.all([
- loadShareViewData(context),
- resolveLocale(),
- ]);
+ const [{ report, sections, documents, documentOnly }, locale] =
+ await Promise.all([loadShareViewData(context), resolveLocale()]);
const { t } = getServerTranslator(locale);
return (
@@ -117,6 +115,7 @@ export default async function ClinicianSharePage({
report={report}
sections={sections}
documents={documents}
+ documentOnly={documentOnly}
token={token}
locale={locale}
/>
diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx
index 3aaaf7344..420c8a399 100644
--- a/src/components/clinician/clinician-view.tsx
+++ b/src/components/clinician/clinician-view.tsx
@@ -31,8 +31,19 @@ interface ClinicianViewProps {
label: string;
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
expiresAt: string;
- report: DoctorReportData;
+ /**
+ * The owner-scoped report payload, or `null` for a documents-only share
+ * (no report section enabled). When `null` NO health metric is rendered —
+ * only the header, disclaimer, and the attached documents.
+ */
+ report: DoctorReportData | null;
sections: DoctorReportPrefs;
+ /**
+ * v1.28.13 — a documents-only link. Hides the reporting-period line (there is
+ * no report) and, together with a `null` report, keeps every health section
+ * off the page. "Share this document" means the document, not the record.
+ */
+ documentOnly?: boolean;
/**
* The frozen document set on this link (metadata only — never bytes). Each
* entry points at the token-scoped serve route (`/c//d/`), the
@@ -183,23 +194,28 @@ export function ClinicianView({
report,
sections,
documents = [],
+ documentOnly = false,
token = "",
locale = "en",
}: ClinicianViewProps) {
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
const fmtNum = (n: number) => Math.round(n * 100) / 100;
- // Measurement series with at least one stat, mapped to their stats row.
- const measurementEntries = Object.entries(report.stats).filter(
- ([, s]) => s.count > 0,
- );
- const glucoseEntries = Object.entries(report.glucoseStats).filter(
- ([, s]) => s.count > 0,
- );
- const complianceEntries = sections.compliance
- ? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
+ // A documents-only share carries no report — the aggregator is never called
+ // server-side, so `report` is null. Every health-derived list below stays
+ // empty by construction and only the attached documents render.
+ const measurementEntries = report
+ ? Object.entries(report.stats).filter(([, s]) => s.count > 0)
: [];
- const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
+ const glucoseEntries = report
+ ? Object.entries(report.glucoseStats).filter(([, s]) => s.count > 0)
+ : [];
+ const complianceEntries =
+ report && sections.compliance
+ ? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
+ : [];
+ const wellness = report?.wellnessScores?.filter((s) => s.count > 0) ?? [];
+ const medications = report?.medications ?? [];
return (
{label}
+ {/* The FHIR API + its resource-type scope expose the health RECORD.
+ A documents-only link serves no record, so both are hidden and the
+ posted scope is forced empty server-side regardless. */}
+ {!documentOnly ? (
+