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 1/2] 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}

) : 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({
-
- - - 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 ? ( +
+ + + setRangeDays(Math.max(1, Number(e.target.value) || 1)) + } + /> +

+ {t("settings.sharing.rangeHint")} +

+
+ ) : null}
-
-
- -

- {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.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 From 2706a978381b4ac8637445457cc99f61c17a3a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 10 Jul 2026 00:28:12 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v1.28.13=20=E2=80=94=20sh?= =?UTF-8?q?aring=20a=20document=20shares=20only=20the=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link created from a document is now document-only: the create route forces an empty report scope and the served view never aggregates a report, so the recipient sees the attached document and no health data. Sharing from Settings keeps the full record form. Earlier document links carry the full record and should be revoked and re-created. --- CHANGELOG.md | 9 +++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d8c00c9..c4f45e725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.28.13] — 2026-07-10 — Sharing a document shares only the document + +Fixes a scope bug: a link created from a document carried the full health record +(vital signs, labs, medications) because an empty report scope was read as "all +sections". A document share is now document-only — it serves the attached +document and no health data — while sharing from Settings keeps the full record +form. NOTE: revoke and re-create any document link made before this release; an +earlier link cannot be narrowed retroactively. + ## [1.28.12] — 2026-07-09 — Lab scans use your AI provider; tidier dashboard Scanning a lab report now uses the AI provider you already configured — including diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9c922c3e0..07a59a596 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.12 + version: 1.28.13 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index f943bfeef..2d3cde4f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.12", + "version": "1.28.13", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 37dda533d..1ada9a62c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.12"; + /* @sw-version-fallback */ "v1.28.13"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`