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}
) : null}
-
- {t("clinicianView.period", {
- start: fmtDate(report.period.start),
- end: fmtDate(report.period.end),
- })}
-
+ {report && !documentOnly ? (
+
+ {t("clinicianView.period", {
+ start: fmtDate(report.period.start),
+ end: fmtDate(report.period.end),
+ })}
+
+ ) : null}
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
@@ -244,7 +262,10 @@ export function ClinicianView({
})}
/>
))}
- {report.bmi !== null && report.bmi !== undefined && sections.bmi ? (
+ {report &&
+ report.bmi !== null &&
+ report.bmi !== undefined &&
+ sections.bmi ? (
0 ? (
+ {medications.length > 0 ? (
- {report.medications.map((med) => {
- const comp = report.compliance[med.name];
+ {medications.map((med) => {
+ const comp = report?.compliance[med.name];
const rate =
sections.compliance && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
@@ -293,9 +314,7 @@ export function ClinicianView({
);
})}
{complianceEntries
- .filter(
- ([name]) => !report.medications.some((m) => m.name === name),
- )
+ .filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
{open ? (
diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx
index bbde00093..707c63ca1 100644
--- a/src/components/settings/__tests__/share-link-create-form.test.tsx
+++ b/src/components/settings/__tests__/share-link-create-form.test.tsx
@@ -25,11 +25,15 @@ vi.mock("@tanstack/react-query", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
-import { ShareLinkCreateForm } from "../share-link-create-form";
+import {
+ ShareLinkCreateForm,
+ buildShareLinkCreatePayload,
+} from "../share-link-create-form";
import type { PickedDocument } from "../share-document-picker";
function render(
props: {
+ documentOnly?: boolean;
initialDocuments?: PickedDocument[];
initialLabel?: string;
} = {},
@@ -81,4 +85,74 @@ describe(" — shared create flow", () => {
// A seeded set surfaces the EXIF note the picker path also shows.
expect(html).toContain("Camera metadata");
});
+
+ it("document-only mode hides every record-scope control but keeps expiry + the doc", () => {
+ const html = render({
+ documentOnly: true,
+ initialDocuments: [{ id: "doc-1", title: "Blood panel 2026" }],
+ initialLabel: "Blood panel 2026",
+ });
+ // No record-scope UI: no FHIR toggle, no FHIR resource-type checkboxes, no
+ // history-range selector — none of those apply to a document link.
+ expect(html).not.toContain('id="share-fhir"');
+ expect(html).not.toContain('id="share-range"');
+ expect(html).not.toContain('id="share-rt-Observation"');
+ // Expiry, label, and the pre-attached document all stay.
+ expect(html).toContain('id="share-expiry"');
+ expect(html).toContain('value="Blood panel 2026"');
+ expect(html).toContain('data-testid="share-attached-chips"');
+ expect(html).toContain("1 of 50 selected");
+ });
+});
+
+describe("buildShareLinkCreatePayload — scope contract", () => {
+ const common = {
+ label: " Blood panel 2026 ",
+ rangeDays: 30,
+ expiryDays: 30,
+ };
+
+ it("document-only: empty report scope, FHIR off, and the launched doc always rides along", () => {
+ const payload = buildShareLinkCreatePayload({
+ ...common,
+ // Even if the caller state still holds record-scope values, they must be
+ // dropped: a document link never carries a health scope.
+ allowFhirApi: true,
+ resourceTypes: ["Patient", "Observation"],
+ documentIds: ["doc-1"],
+ documentOnly: true,
+ });
+ expect(payload.resourceTypes).toEqual([]);
+ expect(payload.allowFhirApi).toBe(false);
+ expect(payload.documentOnly).toBe(true);
+ // No report sections are ever sent for a document link.
+ expect(payload).not.toHaveProperty("sections");
+ // The launched document is always in the created link (pre-attach fix).
+ expect(payload.documentIds).toEqual(["doc-1"]);
+ expect(payload.label).toBe("Blood panel 2026");
+ });
+
+ it("record share: keeps the full scope and omits documentIds when none picked", () => {
+ const payload = buildShareLinkCreatePayload({
+ ...common,
+ allowFhirApi: false,
+ resourceTypes: ["Patient", "Observation"],
+ documentIds: [],
+ documentOnly: false,
+ });
+ expect(payload.resourceTypes).toEqual(["Patient", "Observation"]);
+ expect(payload).not.toHaveProperty("documentIds");
+ expect(payload).not.toHaveProperty("documentOnly");
+ });
+
+ it("record share: attaches the picked documents when the owner chose some", () => {
+ const payload = buildShareLinkCreatePayload({
+ ...common,
+ allowFhirApi: false,
+ resourceTypes: ["Patient", "Observation"],
+ documentIds: ["a", "b"],
+ documentOnly: false,
+ });
+ expect(payload.documentIds).toEqual(["a", "b"]);
+ });
});
diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx
index b193c10a0..299bd663a 100644
--- a/src/components/settings/share-link-create-form.tsx
+++ b/src/components/settings/share-link-create-form.tsx
@@ -94,11 +94,80 @@ function isoDaysFromNow(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString();
}
+/** The create body posted to `POST /api/share-links`. */
+export interface ShareLinkCreatePayload {
+ label: string;
+ rangeStart: string;
+ rangeEnd: null;
+ resourceTypes: ResourceType[];
+ allowFhirApi: boolean;
+ documentIds?: string[];
+ documentOnly?: boolean;
+ expiresAt: string;
+}
+
+/**
+ * Build the create payload from the resolved form state. Extracted as a pure
+ * function so the two surfaces' scope contract is unit-testable without a DOM.
+ *
+ * The `documentOnly` branch is the privacy-load-bearing one: a document-launched
+ * share posts an EMPTY report scope (`resourceTypes: []`, no `sections`, FHIR
+ * off) plus `documentOnly: true`, and ALWAYS carries the picked document ids —
+ * so the created link serves the document(s) and nothing else. The record share
+ * (Settings → Sharing) keeps its full scope and only attaches documents when
+ * the owner picked some.
+ */
+export function buildShareLinkCreatePayload(input: {
+ label: string;
+ rangeDays: number;
+ expiryDays: number;
+ allowFhirApi: boolean;
+ resourceTypes: ResourceType[];
+ documentIds: string[];
+ documentOnly: boolean;
+}): ShareLinkCreatePayload {
+ const base = {
+ label: input.label.trim(),
+ rangeStart: isoDaysFromNow(-input.rangeDays),
+ rangeEnd: null as null,
+ expiresAt: isoDaysFromNow(input.expiryDays),
+ };
+ if (input.documentOnly) {
+ return {
+ ...base,
+ resourceTypes: [],
+ allowFhirApi: false,
+ documentOnly: true,
+ // The launched document(s) always ride along — a documents-only share
+ // with no document would be meaningless (the server 422s it).
+ documentIds: input.documentIds,
+ };
+ }
+ return {
+ ...base,
+ resourceTypes: input.resourceTypes,
+ allowFhirApi: input.allowFhirApi,
+ // Omit the key entirely when nothing is attached (a documents-less record
+ // share stays the default). The server re-validates each id as the caller's
+ // own live document before minting the link.
+ ...(input.documentIds.length > 0 ? { documentIds: input.documentIds } : {}),
+ };
+}
+
export function ShareLinkCreateForm({
+ documentOnly = false,
initialDocuments,
initialLabel,
onCreated,
}: {
+ /**
+ * Documents-only mode (the document-launched flow). Hides the record-scope
+ * controls — FHIR API toggle, FHIR resource-type checkboxes, and the
+ * history-range selector — and posts an empty report scope so the minted
+ * link serves ONLY the attached document(s). Keeps the document picker,
+ * label, expiry, and one-time passphrase reveal.
+ */
+ documentOnly?: boolean;
/** Documents to pre-attach — the document-launched flow seeds the one doc. */
initialDocuments?: PickedDocument[];
/** Optional pre-filled label (e.g. the document title). */
@@ -158,27 +227,23 @@ export function ShareLinkCreateForm({
const createMutation = useMutation({
mutationFn: () => {
- const trimmed = label.trim();
// Surface the same expiry-bound the server enforces before the round
// trip, so the validation feedback is immediate.
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
return Promise.reject(new Error("EXPIRY_RANGE"));
}
- return apiPost("/api/share-links", {
- label: trimmed,
- rangeStart: isoDaysFromNow(-rangeDays),
- 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),
- });
+ return apiPost(
+ "/api/share-links",
+ buildShareLinkCreatePayload({
+ label,
+ rangeDays,
+ expiryDays,
+ allowFhirApi,
+ resourceTypes,
+ documentIds: selectedDocs.map((d) => d.id),
+ documentOnly,
+ }),
+ );
},
onSuccess: (result) => {
// Clear any stale QR before the effect renders the new one.
@@ -240,22 +305,26 @@ export function ShareLinkCreateForm({
-
-
{t("settings.sharing.range")}
-
- setRangeDays(Math.max(1, Number(e.target.value) || 1))
- }
- />
-
- {t("settings.sharing.rangeHint")}
-
-
+ {/* The history-range window only scopes the report — meaningless for
+ a documents-only share, so it is hidden there. */}
+ {!documentOnly ? (
+
+
{t("settings.sharing.range")}
+
+ setRangeDays(Math.max(1, Number(e.target.value) || 1))
+ }
+ />
+
+ {t("settings.sharing.rangeHint")}
+
+
+ ) : null}
{t("settings.sharing.expiry")}
-
-
-
- {t("settings.sharing.fhirApi")}
-
-
- {t("settings.sharing.fhirApiHint")}
-
+ {/* 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 ? (
+
+
+
+ {t("settings.sharing.fhirApi")}
+
+
+ {t("settings.sharing.fhirApiHint")}
+
+
+
-
-
+ ) : null}
- {allowFhirApi && (
+ {!documentOnly && allowFhirApi && (
{t("settings.sharing.resourceTypes")}
diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts
index 5dfe72a85..0cbe7c7f1 100644
--- a/src/lib/clinician-share/__tests__/share-view-data.test.ts
+++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts
@@ -13,9 +13,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
}));
-vi.mock("@/lib/validations/doctor-report-prefs", () => ({
- parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}),
-}));
+// The real prefs parser is used so the "no section enabled ⇒ documents-only"
+// signal is exercised end to end (a stubbed parser would let the test lie
+// about which sectionsJson resolves to an empty report scope).
vi.mock("@/lib/db", () => ({
prisma: {
clinicianShareLinkDocument: { findMany: vi.fn() },
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
import { loadShareViewData } from "../share-view-data";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
+import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import { prisma } from "@/lib/db";
import type { ShareContext } from "../resolve-share-token";
@@ -154,3 +155,68 @@ describe("loadShareViewData — KVNR default OFF", () => {
expect(range.days).toBeGreaterThan(0);
});
});
+
+/**
+ * The load-bearing privacy guarantee: a documents-only share (every report
+ * section OFF) serves ZERO health metrics. The doctor-report aggregator is
+ * never called, so no vital / lab / medication / wellness figure ever leaves
+ * the database — the recipient sees only the attached document(s). "Share this
+ * document" means the document, not the whole record.
+ */
+describe("loadShareViewData — documents-only share exposes no health data", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
+ findDocs.mockResolvedValue([]);
+ });
+
+ it("never aggregates a report and returns report=null when no section is enabled", 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",
+ },
+ },
+ ]);
+
+ const { report, documentOnly, documents } = await loadShareViewData(
+ ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS }),
+ );
+
+ // The one guarantee: the aggregator is NEVER invoked — no health data is
+ // read from the DB, let alone served.
+ expect(collect).not.toHaveBeenCalled();
+ expect(report).toBeNull();
+ expect(documentOnly).toBe(true);
+
+ // The attached document is still surfaced (metadata only, never bytes).
+ expect(documents).toEqual([
+ {
+ id: "doc-a",
+ title: "Blood panel",
+ kind: "LAB_REPORT",
+ documentDate: "2026-01-15",
+ byteSize: 12345,
+ mimeType: "application/pdf",
+ servingClass: "inline",
+ },
+ ]);
+ });
+
+ it("still aggregates for a record share (defaults resolve to an enabled scope)", async () => {
+ // `{}` / null sections resolve to the documented defaults (a full record
+ // share), so the aggregator DOES run — the empty-scope short-circuit must
+ // not swallow a normal record share.
+ const { report, documentOnly } = await loadShareViewData(
+ ctx({ sectionsJson: {} }),
+ );
+ expect(collect).toHaveBeenCalledTimes(1);
+ expect(documentOnly).toBe(false);
+ expect(report).not.toBeNull();
+ });
+});
diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts
index 43e2360b7..10f15774c 100644
--- a/src/lib/clinician-share/share-view-data.ts
+++ b/src/lib/clinician-share/share-view-data.ts
@@ -20,7 +20,10 @@ import {
} 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 {
+ hasAnyReportSection,
+ parseDoctorReportPrefs,
+} from "@/lib/validations/doctor-report-prefs";
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
/**
@@ -47,12 +50,23 @@ export interface ShareViewDocument {
}
export interface ShareViewData {
- /** The aggregated, owner-scoped report payload over the frozen window. */
- report: DoctorReportData;
+ /**
+ * The aggregated, owner-scoped report payload over the frozen window, or
+ * `null` for a documents-only share. `null` is the load-bearing privacy
+ * state: the aggregator is NEVER called, so no health data leaves the DB —
+ * the recipient sees only the attached documents.
+ */
+ report: DoctorReportData | null;
/** The resolved section toggles (mood opt-in, defaults otherwise). */
sections: ReturnType;
/** v1.28 — the hand-picked documents on this link (metadata only). */
documents: ShareViewDocument[];
+ /**
+ * v1.28.13 — whether this link carries ONLY documents (no report section
+ * enabled). The public view reads it to render a documents-only surface with
+ * no health-record chrome.
+ */
+ documentOnly: boolean;
}
/**
@@ -81,12 +95,19 @@ export async function loadShareViewData(
const sections = parseDoctorReportPrefs(context.sectionsJson);
const range = frozenRange(context);
+ // A share with NO report section enabled is a documents-only share: never
+ // aggregate — no health metric is read from the DB, let alone served. This is
+ // the load-bearing guarantee behind "share this document, not the record".
+ const documentOnly = !hasAnyReportSection(sections);
+
const [report, documents] = await Promise.all([
- collectDoctorReportData(context.ownerUserId, range, { sections }),
+ documentOnly
+ ? Promise.resolve(null)
+ : collectDoctorReportData(context.ownerUserId, range, { sections }),
loadShareDocuments(context),
]);
- return { report, sections, documents };
+ return { report, sections, documents, documentOnly };
}
/**
diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts
index 41fe80d54..fb98b488e 100644
--- a/src/lib/validations/clinician-share-link.ts
+++ b/src/lib/validations/clinician-share-link.ts
@@ -62,6 +62,16 @@ export const createShareLinkSchema = z
.array(z.string().trim().min(1).max(64))
.max(SHARE_LINK_MAX_DOCUMENTS)
.optional(),
+ /**
+ * v1.28.13 — a documents-only share. When true the server FORCES an empty
+ * report scope (no sections, no FHIR resource types, FHIR API off) and
+ * mints a link that serves ONLY the attached documents — never a health
+ * metric. "Share this document" means the document, not the whole record.
+ * A documents-only share MUST carry at least one `documentId` (enforced
+ * below). Any `sections` / `resourceTypes` / `allowFhirApi` sent alongside
+ * are ignored server-side so a document link can never widen into a record.
+ */
+ documentOnly: z.boolean().optional(),
expiresAt: z.iso
.datetime({ offset: true })
.refine((v) => new Date(v).getTime() > Date.now(), {
@@ -77,7 +87,11 @@ export const createShareLinkSchema = z
v.rangeEnd == null ||
new Date(v.rangeEnd).getTime() >= new Date(v.rangeStart).getTime(),
{ message: "rangeEnd must not precede rangeStart", path: ["rangeEnd"] },
- );
+ )
+ .refine((v) => !v.documentOnly || (v.documentIds?.length ?? 0) > 0, {
+ message: "A documents-only share must carry at least one document",
+ path: ["documentIds"],
+ });
export type CreateShareLinkInput = z.infer;
diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts
index e19e93df1..c4a0daddb 100644
--- a/src/lib/validations/doctor-report-prefs.ts
+++ b/src/lib/validations/doctor-report-prefs.ts
@@ -87,6 +87,38 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
familyHistory: true,
};
+/**
+ * The "no section" shape — every toggle OFF. A share link that carries ONLY
+ * documents (the "share this document, not the whole record" flow) persists
+ * this so the clinician view resolves to an empty report scope and NO health
+ * data is ever aggregated for it. Distinct from the null / `{}` case, which
+ * resolves to {@link DEFAULT_DOCTOR_REPORT_PREFS} (a full record share).
+ */
+export const EMPTY_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
+ bp: false,
+ weight: false,
+ pulse: false,
+ bmi: false,
+ mood: false,
+ compliance: false,
+ sleep: false,
+ cycle: false,
+ labs: false,
+ allergies: false,
+ familyHistory: false,
+};
+
+/**
+ * Whether any report section is enabled. A resolved prefs object with every
+ * flag OFF means "no health report" — the load-bearing signal a documents-only
+ * share reads to serve zero metrics. The clinician-view data loader gates the
+ * whole doctor-report aggregation on this: false ⇒ never touch the DB for
+ * health data.
+ */
+export function hasAnyReportSection(prefs: DoctorReportPrefs): boolean {
+ return Object.values(prefs).some(Boolean);
+}
+
/**
* Parse a row's `doctorReportPrefsJson` Json blob into a typed
* `DoctorReportPrefs`, falling back to the documented defaults when the