From 989381b3f6ec96db9358ba4a6986375e180aba24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 12:56:25 +0100 Subject: [PATCH] fix(share): stop serving shared documents once the documents module is off A share link gated its report on the doctorReport module but listed and served its attached documents unconditionally, although the comment beside the gate said the documents kept a gate of their own. An owner who switched the documents module off still had every attached file decrypted for anyone holding the link and its passphrase. The share page lists no document while the module is off, and the document route answers 404, the same as for an unknown document, before it looks the membership up. --- .../c/[token]/d/[id]/__tests__/route.test.ts | 15 +++++++++++++++ src/app/c/[token]/d/[id]/route.ts | 6 ++++++ .../__tests__/share-view-data.test.ts | 19 +++++++++++++++++++ src/lib/clinician-share/share-view-data.ts | 8 ++++++-- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts index 93adec25a..b22022580 100644 --- a/src/app/c/[token]/d/[id]/__tests__/route.test.ts +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -49,6 +49,9 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -70,6 +73,7 @@ import { import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; import { encryptDocumentToBytes } from "@/lib/documents/store"; +import { isModuleEnabled } from "@/lib/modules/gate"; const TOKEN = "hls_000000000000000000000000000000000000000000000000"; const OWNER = "owner-1"; @@ -164,6 +168,17 @@ beforeEach(() => { }); describe("GET /c/[token]/d/[id] — share document serve", () => { + it("answers 404 once the owner switched the documents module off", async () => { + seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); + vi.mocked(isModuleEnabled).mockResolvedValueOnce(false); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(404); + expect(isModuleEnabled).toHaveBeenCalledWith(OWNER, "inboundDocuments"); + expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled(); + }); + it("serves a PDF inline through the token, confined to the frozen set", async () => { seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); diff --git a/src/app/c/[token]/d/[id]/route.ts b/src/app/c/[token]/d/[id]/route.ts index 54d050d81..7e16d6482 100644 --- a/src/app/c/[token]/d/[id]/route.ts +++ b/src/app/c/[token]/d/[id]/route.ts @@ -52,6 +52,7 @@ import { prisma } from "@/lib/db"; import { decryptDocumentContent } from "@/lib/documents/store"; import { servingClassFor } from "@/lib/documents/upload-policy"; import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; type RouteParams = { params: Promise<{ token: string; id: string }> }; @@ -169,6 +170,11 @@ export const GET = apiHandler( // access counters are bumped fire-and-forget. const context = await resolveShareToken(token); if (!context) return notFound(); + // The owner switched the documents module off: no document leaves, the + // same answer as an unknown one. + if (!(await isModuleEnabled(context.ownerUserId, "inboundDocuments"))) { + return notFound(); + } // 5. Confine to the frozen set: the document must be a member of THIS // link, owned by the link's owner, and live. The composite lookup ties 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 7a97cee91..d4bb99991 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -113,6 +113,25 @@ describe("loadShareViewData — KVNR default OFF", () => { expect(patient).not.toHaveProperty("kvnr"); }); + it("lists no document once the owner switched the documents module off", async () => { + moduleMap.mockResolvedValueOnce(modulesWithout("inboundDocuments")); + findDocs.mockResolvedValue([ + { + document: { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: null, + byteSize: 1, + mimeType: "application/pdf", + }, + }, + ]); + const { documents } = await loadShareViewData(ctx()); + expect(documents).toEqual([]); + expect(findDocs).not.toHaveBeenCalled(); + }); + it("lists the frozen document set as metadata only (never bytes)", async () => { findDocs.mockResolvedValue([ { diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 69d899033..b8d15d972 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -142,7 +142,7 @@ export async function loadShareViewData( // (report `null`, aggregator never called), so a disabled module collapses // the link to exactly the documents the owner attached. That is fail-closed // for the health record while keeping a public link from 500-ing. The - // documents themselves are a separate module and keep their own gate. + // documents themselves are a separate module with their own gate below. // // The whole map is resolved rather than the one key, because the per-leaf // verdicts below come off the same read. It is NOT handed to the aggregator: @@ -168,7 +168,11 @@ export async function loadShareViewData( documentOnly ? Promise.resolve(null) : collectDoctorReportData(context.ownerUserId, range, selection), - loadShareDocuments(context), + // With the documents module off, the link lists no document, and the + // serve route (`/c/[token]/d/[id]`) answers 404 for each of them. + moduleMap.inboundDocuments === false + ? Promise.resolve([]) + : loadShareDocuments(context), ]); return { report, selection, unavailableLeaves, documents, documentOnly };