fix(clinician-share): a document share exposes only the document

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/<token> 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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 00:18:57 +02:00
1 parent fa42c8bc0b
commit d4965033c2
13 files changed
+503 -92

No files matched your search

@@ -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", () => {
+18 -3
View File
@@ -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