diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f6ba25b41..c894483a0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -998,8 +998,11 @@ paths: ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen - write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited - (`share-link:`, 20/h). Strict: unknown keys 422." + write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's + own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports + `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` + is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). + Strict: unknown keys 422." requestBody: required: true content: @@ -1096,6 +1099,13 @@ paths: - MedicationAdministration allowFhirApi: type: boolean + documentIds: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 64 expiresAt: type: string format: date-time @@ -1204,6 +1214,57 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /c/{token}/d/{id}: + get: + tags: + - Export + summary: Serve a shared document blob (v1.28, public) + description: "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the + ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in + the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected + link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the + same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: + Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. + Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); + PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + - in: path + name: id + schema: + type: string + description: A document id on the link's frozen set. + required: true + description: A document id on the link's frozen set. + responses: + "200": + description: The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` + for Class B (attachment). + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the + frozen set). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link serve rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: @@ -13087,6 +13148,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -13124,6 +13187,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt @@ -13176,6 +13240,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -13200,6 +13266,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt diff --git a/prisma/migrations/0229_share_link_documents/migration.sql b/prisma/migrations/0229_share_link_documents/migration.sql new file mode 100644 index 000000000..6f7baac5d --- /dev/null +++ b/prisma/migrations/0229_share_link_documents/migration.sql @@ -0,0 +1,45 @@ +-- Document vault, Phase 3: attach hand-picked documents to a clinician share +-- link. +-- +-- `clinician_share_link_documents` — frozen, write-once membership rows +-- binding a stored document to a share link. The document set a share +-- exposes is modelled as this join, never a mutable column on the link, so +-- it inherits the link's frozen-scope discipline: rows are created in the +-- same transaction as the link, each document is owner-checked at create, +-- and the set is never widened afterwards. The public share serve route +-- serves ONLY a document id present in this set for the resolved token. +-- +-- Both foreign keys cascade: deleting the link or the underlying document +-- removes the membership cleanly (a shared document the owner later deletes +-- simply disappears from the link). +-- +-- Additive; applies clean on a 0228 database and on a fresh database as part +-- of the ordered migration chain. + +CREATE TABLE "clinician_share_link_documents" ( + "id" TEXT NOT NULL, + "share_link_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key" + ON "clinician_share_link_documents" ("share_link_id", "document_id"); + +CREATE INDEX "clinician_share_link_documents_share_link_id_idx" + ON "clinician_share_link_documents" ("share_link_id"); + +CREATE INDEX "clinician_share_link_documents_document_id_idx" + ON "clinician_share_link_documents" ("document_id"); + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey" + FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b8b4529b..f3f5d2fa1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4853,13 +4853,41 @@ model ClinicianShareLink { lastAccessAt DateTime? @map("last_access_at") accessCount Int @default(0) @map("access_count") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// v1.28 — the hand-picked document set this link exposes (0..N). A relation, + /// not a column: frozen write-once at create, owner-checked, never widened. + documents ClinicianShareLinkDocument[] @@index([userId, createdAt(sort: Desc)]) @@index([expiresAt]) @@map("clinician_share_links") } +/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a +/// stored document to a clinician share link. The document set a share exposes +/// is modelled as this relation, never a mutable column: the rows are created +/// in the same transaction as the link, each `documentId` is validated as the +/// caller's own live document at create time, and the set is never widened +/// afterwards. The public share serve route serves ONLY a document id present +/// in this set for the resolved token — a guessed / foreign id is a flat 404. +/// Both foreign keys cascade: deleting the link or the underlying document +/// removes the membership cleanly (a shared document the owner later deletes +/// simply disappears from the link). +model ClinicianShareLinkDocument { + id String @id @default(cuid()) + shareLinkId String @map("share_link_id") + documentId String @map("document_id") + createdAt DateTime @default(now()) @map("created_at") + + shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + @@unique([shareLinkId, documentId]) + @@index([shareLinkId]) + @@index([documentId]) + @@map("clinician_share_link_documents") +} + // v1.11.0 W3 — period-narrative cache (Pillar P1). // // One durable, typed row per (user, period, locale) carrying the latest @@ -5819,6 +5847,9 @@ model InboundDocument { /// 1:1 blind content-search index (encrypted extracted text + HMAC token /// array). Absent until the document is indexed; cascades on delete. contentIndex DocumentContentIndex? + /// share-link memberships (m:n via `ClinicianShareLinkDocument`). + /// Cascades: deleting this document removes it from every share it was on. + shareLinks ClinicianShareLinkDocument[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/__tests__/proxy-document-serve-framing.test.ts b/src/__tests__/proxy-document-serve-framing.test.ts index 97cdb1d9c..a0353575c 100644 --- a/src/__tests__/proxy-document-serve-framing.test.ts +++ b/src/__tests__/proxy-document-serve-framing.test.ts @@ -86,3 +86,42 @@ describe("proxy.ts document serve-route framing carve-out", () => { ); }); }); + +describe("proxy.ts share document serve-route framing carve-out", () => { + const shareToken = "hls_000000000000000000000000000000000000000000000000"; + + it("allows same-origin framing on the /c//d/ serve path", () => { + const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`)); + expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN"); + const csp = res.headers.get("content-security-policy") ?? ""; + expect(csp).toContain("frame-ancestors 'self'"); + expect(csp).toContain("default-src 'none'"); + // No sandbox (Chromium downloads sandboxed PDFs) and no script sources. + expect(csp).not.toMatch(/sandbox/); + expect(csp).not.toMatch(/script-src/); + }); + + it("keeps the DENY + page-CSP posture on the /c/ view page itself", () => { + const res = proxy(makeRequest(`/c/${shareToken}`)); + // The clinician view is a PAGE — it must not be framable and keeps the + // page CSP; only the blob subresource under it takes the serve posture. + expect(res.headers.get("x-frame-options")).toBe("DENY"); + expect(res.headers.get("content-security-policy") ?? "").toContain( + "frame-ancestors 'none'", + ); + }); + + it("does not match nested, suffixed, or lookalike share serve paths", () => { + for (const path of [ + `/c/${shareToken}/d/doc123/extra`, // extra trailing segment + `/c/${shareToken}/d/doc123x/more/deep`, // deep suffix + `/c/${shareToken}/dx/doc123`, // wrong segment name + `/c/${shareToken}/d`, // missing id + `/c/${shareToken}/d/`, // empty id + "/c//d/doc123", // empty token (traversal-ish) + ]) { + const res = proxy(makeRequest(path)); + expect(res.headers.get("x-frame-options"), path).toBe("DENY"); + } + }); +}); diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index db045fab0..2398adafd 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), updateMany: vi.fn(), }, + inboundDocument: { + findMany: vi.fn(), + }, auditLog: { create: vi.fn() }, }, })); @@ -81,6 +84,7 @@ function storedRow(overrides: Record = {}) { revokedAt: null, lastAccessAt: null, accessCount: 0, + _count: { documents: 0 }, ...overrides, }; } @@ -178,6 +182,84 @@ describe("POST /api/share-links — create", () => { expect(res.status).toBe(422); expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); }); + + it("attaches own live documents in the create transaction (frozen write-once)", async () => { + // The ownership probe returns exactly the requested (deduped) ids as live. + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "doc-a" }, + { id: "doc-b" }, + ] as never); + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow({ _count: { documents: 2 } }) as never, + ); + + const res = await POST( + postReq(validBody({ documentIds: ["doc-a", "doc-b", "doc-a"] })), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { documentCount: number } }; + expect(body.data.documentCount).toBe(2); + + // Ownership was checked against the caller's own LIVE documents. + const probeArg = vi.mocked(prisma.inboundDocument.findMany).mock + .calls[0][0]; + expect(probeArg!.where).toEqual({ + id: { in: ["doc-a", "doc-b"] }, // deduped + userId: "user-1", + deletedAt: null, + }); + + // Membership rows are created NESTED in the same create call (one txn), + // deduped, never widened afterwards. + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + expect(createArg.data.documents).toEqual({ + create: [{ documentId: "doc-a" }, { documentId: "doc-b" }], + }); + }); + + it("rejects a foreign / deleted document id at create (422, no link minted)", async () => { + // Only one of the two requested ids resolves as the caller's own live doc. + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "doc-a" }, + ] as never); + + const res = await POST( + postReq(validBody({ documentIds: ["doc-a", "not-mine"] })), + ); + expect(res.status).toBe(422); + // No share is ever minted with a document the caller does not own (B5). + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + + it("rejects more than the document cap via strict schema (422)", async () => { + const tooMany = Array.from({ length: 51 }, (_, i) => `doc-${i}`); + const res = await POST(postReq(validBody({ documentIds: tooMany }))); + expect(res.status).toBe(422); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + + it("allows a documents-only share (empty report sections)", 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, + ); + const res = await POST( + postReq( + validBody({ + resourceTypes: [], + allowFhirApi: false, + documentIds: ["doc-a"], + }), + ), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { documentCount: number } }; + expect(body.data.documentCount).toBe(1); + }); }); 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 1fa5d539c..94111c12a 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -54,6 +54,27 @@ function resolveOrigin(request: NextRequest): string { return `${proto}://${host}`; } +/** + * The row selection every owner-facing projection reads. `_count.documents` + * surfaces the size of the frozen document set without ever loading the ids + * (let alone the blobs) — the metadata-only discipline (P3-D5). + */ +const shareLinkSummarySelect = { + id: true, + label: true, + rangeStart: true, + rangeEnd: true, + resourceTypes: true, + allowFhirApi: true, + passphraseHash: true, + expiresAt: true, + createdAt: true, + revokedAt: true, + lastAccessAt: true, + accessCount: true, + _count: { select: { documents: true } }, +} as const; + /** Project a stored row to the safe owner-facing shape (never the token). */ function toSummary(row: { id: string; @@ -68,6 +89,7 @@ function toSummary(row: { revokedAt: Date | null; lastAccessAt: Date | null; accessCount: number; + _count: { documents: number }; }) { return { id: row.id, @@ -79,6 +101,9 @@ function toSummary(row: { // v1.18.7 — surface only WHETHER a passphrase guards the link, never the // hash itself. Legacy (null-hash) links read `false` and stay ungated. protected: row.passphraseHash !== null, + // v1.28 — how many documents this share carries (never the ids, never the + // bytes; the serve route is the only decrypt path). + documentCount: row._count.documents, expiresAt: row.expiresAt.toISOString(), createdAt: row.createdAt.toISOString(), revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null, @@ -120,6 +145,26 @@ export const POST = apiHandler(async (request: NextRequest) => { const rawPassphrase = generatePassphrase(); const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!); + // v1.28 — resolve the frozen document set. The ids are deduped (a client + // could repeat one, which the unique membership index would otherwise + // reject) and then validated as the caller's OWN live documents. A single + // foreign / deleted / unknown id seals the whole create as 422 — no share is + // ever minted with a document the caller does not own (B5). The rows are + // created in the SAME transaction as the link (nested create), so the + // membership is frozen write-once atomically with the scope columns. + const documentIds = Array.from(new Set(input.documentIds ?? [])); + if (documentIds.length > 0) { + const owned = await prisma.inboundDocument.findMany({ + where: { id: { in: documentIds }, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (owned.length !== documentIds.length) { + return apiError("One or more documents are not available to share", 422, { + errorCode: "share-link.documents.invalid", + }); + } + } + // Field-by-field build — no mass assignment. Scope columns are written // exactly once here and never updated. const created = await prisma.clinicianShareLink.create({ @@ -134,21 +179,12 @@ export const POST = apiHandler(async (request: NextRequest) => { resourceTypes: input.resourceTypes ?? [], allowFhirApi: input.allowFhirApi ?? false, expiresAt: new Date(input.expiresAt), + documents: + documentIds.length > 0 + ? { create: documentIds.map((documentId) => ({ documentId })) } + : undefined, }, - select: { - id: true, - label: true, - rangeStart: true, - rangeEnd: true, - resourceTypes: true, - allowFhirApi: true, - passphraseHash: true, - expiresAt: true, - createdAt: true, - revokedAt: true, - lastAccessAt: true, - accessCount: true, - }, + select: shareLinkSummarySelect, }); await auditLog("share-link.create", { @@ -158,6 +194,7 @@ export const POST = apiHandler(async (request: NextRequest) => { shareLinkId: created.id, resourceTypes: created.resourceTypes, allowFhirApi: created.allowFhirApi, + documentCount: created._count.documents, expiresAt: created.expiresAt.toISOString(), }, }); @@ -188,20 +225,7 @@ export const GET = apiHandler(async () => { const rows = await prisma.clinicianShareLink.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, - select: { - id: true, - label: true, - rangeStart: true, - rangeEnd: true, - resourceTypes: true, - allowFhirApi: true, - passphraseHash: true, - expiresAt: true, - createdAt: true, - revokedAt: true, - lastAccessAt: true, - accessCount: true, - }, + select: shareLinkSummarySelect, }); return apiSuccess({ shareLinks: rows.map(toSummary) }); diff --git a/src/app/c/[token]/d/[id]/__tests__/route.test.ts b/src/app/c/[token]/d/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..96072c5c7 --- /dev/null +++ b/src/app/c/[token]/d/[id]/__tests__/route.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { NextRequest } from "next/server"; + +/** + * v1.28 (document vault, Phase 3) — public share-scoped document serve route + * `GET /c/[token]/d/[id]`. + * + * Load-bearing behaviour under test: it resolves ONLY via the share token + * (never a session), confines serving to the link's frozen document set (a + * foreign / unknown / soft-deleted id → flat 404), re-enforces the passphrase + * gate and revocation/expiry BEFORE any decrypt, applies the identical + * serving-class posture as the owner route (inline Class A / attachment Class + * B + octet-stream + nosniff + no-store, no route-level CSP), strips EXIF from + * raster images on egress, and fails closed on a decrypt error. + */ + +// Reversible crypto stub (mirrors the owner /original route test) so the +// document codec round-trips without real keys. +const decryptMock = vi.fn((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), +); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`, + decrypt: (s: string) => decryptMock(s), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLinkDocument: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/clinician-share/resolve-share-token", () => ({ + resolveShareGateState: vi.fn(), + resolveShareToken: vi.fn(), +})); + +vi.mock("@/lib/clinician-share/unlock-cookie", () => ({ + unlockCookieName: (h: string) => `hls_unlock_${h.slice(0, 16)}`, + verifyUnlockValue: vi.fn(() => true), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 299, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { + resolveShareGateState, + resolveShareToken, +} from "@/lib/clinician-share/resolve-share-token"; +import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { encryptDocumentToBytes } from "@/lib/documents/store"; + +const TOKEN = "hls_000000000000000000000000000000000000000000000000"; +const OWNER = "owner-1"; +const SHARE_LINK_ID = "link-1"; + +type Ctx = { params: Promise<{ token: string; id: string }> }; +const callGet = GET as unknown as ( + req: NextRequest, + ctx: Ctx, +) => Promise; + +function makeReq(id: string): NextRequest { + return new NextRequest(new URL(`http://localhost/c/${TOKEN}/d/${id}`)); +} +function ctx(id: string): Ctx { + return { params: Promise.resolve({ token: TOKEN, id }) }; +} + +/** Seed a membership row whose document decrypts to `bytes` of `mimeType`. */ +function seedMembership(opts: { + id?: string; + userId?: string; + filename?: string | null; + mimeType: string; + bytes: Buffer; + deletedAt?: Date | null; +}) { + vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue({ + document: { + id: opts.id ?? "doc-1", + userId: opts.userId ?? OWNER, + filename: opts.filename ?? "report.pdf", + mimeType: opts.mimeType, + contentCodec: "base64v1", + contentEncrypted: encryptDocumentToBytes(opts.bytes), + deletedAt: opts.deletedAt ?? null, + }, + } as never); +} + +const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body"); + +// A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment. +function jpegWithGps(): Buffer { + const payload = Buffer.concat([ + Buffer.from("Exif\0\0"), + Buffer.from("GPSLatitude=48.137"), + ]); + const app1 = Buffer.concat([ + Buffer.from([0xff, 0xe1]), + (() => { + const b = Buffer.alloc(2); + b.writeUInt16BE(payload.length + 2); + return b; + })(), + payload, + ]); + const sos = Buffer.concat([ + Buffer.from([0xff, 0xda]), + Buffer.from([0x00, 0x08]), + Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]), + Buffer.from([0xaa, 0xbb]), + ]); + return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]); +} + +beforeEach(() => { + vi.clearAllMocks(); + decryptMock.mockImplementation((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), + ); + vi.mocked(verifyUnlockValue).mockReturnValue(true); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 299, + resetAt: Date.now(), + } as never); + // Default: a live, unprotected link resolving to the owner scope. + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: "hash-1", + passphraseHash: null, + } as never); + vi.mocked(resolveShareToken).mockResolvedValue({ + shareLinkId: SHARE_LINK_ID, + ownerUserId: OWNER, + label: "Clinic", + rangeStart: new Date(), + rangeEnd: null, + sectionsJson: {}, + resourceTypes: [], + allowFhirApi: false, + expiresAt: new Date(Date.now() + 3_600_000), + } as never); +}); + +describe("GET /c/[token]/d/[id] — share document serve", () => { + it("serves a PDF inline through the token, confined to the frozen set", async () => { + seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/pdf"); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + // The response CSP is proxy-owned; the route sets none. + expect(res.headers.get("Content-Security-Policy")).toBeNull(); + const out = Buffer.from(await res.arrayBuffer()); + expect(out.equals(PDF_BYTES)).toBe(true); + + // The membership lookup ties the id to THIS link (token-confined scope). + const arg = vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mock + .calls[0][0]; + expect(arg!.where).toEqual({ + shareLinkId_documentId: { + shareLinkId: SHARE_LINK_ID, + documentId: "doc-1", + }, + }); + }); + + it("strips EXIF/GPS from a shared JPEG on egress", async () => { + const jpeg = jpegWithGps(); + seedMembership({ mimeType: "image/jpeg", bytes: jpeg, filename: "p.jpg" }); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(200); + const out = Buffer.from(await res.arrayBuffer()); + // The GPS marker is gone; the image is strictly smaller than the original. + expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false); + expect(out.length).toBeLessThan(jpeg.length); + // Still an inline image. + expect(res.headers.get("Content-Type")).toBe("image/jpeg"); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + }); + + it("serves a Class B document as an opaque attachment (never inline)", async () => { + seedMembership({ + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + bytes: Buffer.from("docx payload"), + filename: "letter.docx", + }); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + const disposition = res.headers.get("Content-Disposition") ?? ""; + expect(disposition.startsWith("attachment;")).toBe(true); + expect(disposition).toContain("letter.docx"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("never serves a would-be HTML/SVG payload inline (byte-class denies it)", async () => { + // HTML/SVG can never be stored (upload deny-list); even if a row carried + // such a type, the serving class forces attachment + octet-stream so it + // can never script in-origin. + for (const mime of ["text/html", "image/svg+xml"]) { + seedMembership({ mimeType: mime, bytes: Buffer.from("") }); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.headers.get("Content-Type")).toBe("application/octet-stream"); + expect(res.headers.get("Content-Disposition")).toContain("attachment"); + } + }); + + it("404s a document id not on the link's frozen set", async () => { + vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue( + null as never, + ); + const res = await callGet(makeReq("foreign"), ctx("foreign")); + expect(res.status).toBe(404); + }); + + it("404s a soft-deleted document even if the membership row survives", async () => { + seedMembership({ + mimeType: "application/pdf", + bytes: PDF_BYTES, + deletedAt: new Date(), + }); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(404); + }); + + it("404s when the document owner does not match the token owner", async () => { + seedMembership({ + mimeType: "application/pdf", + bytes: PDF_BYTES, + userId: "someone-else", + }); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(404); + }); + + it("serves NOTHING for a revoked/expired/unknown token (gate null) — no decrypt", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue(null as never); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(404); + // Revocation is enforced BEFORE any decrypt or membership lookup. + expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled(); + expect(resolveShareToken).not.toHaveBeenCalled(); + }); + + it("serves NOTHING for a protected link without a valid unlock cookie", async () => { + vi.mocked(resolveShareGateState).mockResolvedValue({ + tokenHash: "hash-1", + passphraseHash: "hash(pass)", + } as never); + vi.mocked(verifyUnlockValue).mockReturnValue(false); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(404); + // The passphrase gate stands in front of any decrypt. + expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled(); + }); + + it("429s when the per-link serve rate limit trips", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now(), + } as never); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(429); + expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled(); + }); + + it("fails closed (500) on a decrypt error — never serves ciphertext", async () => { + seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES }); + decryptMock.mockImplementation(() => { + throw new Error("unknown key id"); + }); + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); +}); diff --git a/src/app/c/[token]/d/[id]/route.ts b/src/app/c/[token]/d/[id]/route.ts new file mode 100644 index 000000000..7e50337e5 Binary files /dev/null and b/src/app/c/[token]/d/[id]/route.ts differ diff --git a/src/lib/clinician-share/__tests__/exif-strip.test.ts b/src/lib/clinician-share/__tests__/exif-strip.test.ts new file mode 100644 index 000000000..5948ad966 --- /dev/null +++ b/src/lib/clinician-share/__tests__/exif-strip.test.ts @@ -0,0 +1,188 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; + +import { stripImageMetadata } from "@/lib/clinician-share/exif-strip"; + +/** + * The stripper walks container structure and drops metadata carriers without + * decoding pixels. These fixtures are hand-built minimal-but-valid segment + * streams so we can assert exactly which markers survive. + */ + +function u16be(n: number): Buffer { + const b = Buffer.alloc(2); + b.writeUInt16BE(n); + return b; +} +function u32be(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n); + return b; +} +function u32le(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32LE(n); + return b; +} + +/** A JPEG segment: FF . */ +function jpegSegment(marker: number, payload: Buffer): Buffer { + return Buffer.concat([ + Buffer.from([0xff, marker]), + u16be(payload.length + 2), + payload, + ]); +} + +describe("stripImageMetadata — passthrough", () => { + it("leaves non-strippable types byte-for-byte unchanged", () => { + const pdf = Buffer.from("%PDF-1.4 fake pdf body"); + expect(stripImageMetadata(pdf, "application/pdf")).toBe(pdf); + const tiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]); + expect(stripImageMetadata(tiff, "image/tiff")).toBe(tiff); + const heic = Buffer.from("ftyp-heic-fake"); + expect(stripImageMetadata(heic, "image/heic")).toBe(heic); + }); + + it("returns malformed input for the claimed type unchanged (fail-safe)", () => { + const notJpeg = Buffer.from([0x00, 0x01, 0x02, 0x03]); + expect(stripImageMetadata(notJpeg, "image/jpeg")).toBe(notJpeg); + }); +}); + +describe("stripImageMetadata — JPEG", () => { + // SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan. + const soi = Buffer.from([0xff, 0xd8]); + const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0")); + const exifPayload = Buffer.concat([ + Buffer.from("Exif\0\0"), + // A recognisable GPS marker string we can assert is gone. + Buffer.from("GPSLatitude=48.137 GPSLongitude=11.575"), + ]); + const app1 = jpegSegment(0xe1, exifPayload); + const iccPayload = Buffer.concat([ + Buffer.from("ICC_PROFILE\0"), + Buffer.from([1, 2, 3, 4]), + ]); + const app2 = jpegSegment(0xe2, iccPayload); + const com = jpegSegment(0xfe, Buffer.from("private comment")); + const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03])); + const sosAndScan = Buffer.concat([ + Buffer.from([0xff, 0xda]), + u16be(2 + 6), + Buffer.from([0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]), + Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]), // entropy-coded scan bytes + ]); + const jpeg = Buffer.concat([soi, app0, app1, app2, com, dqt, sosAndScan]); + + it("removes EXIF/GPS, XMP-carrying APP1, and the comment segment", () => { + const out = stripImageMetadata(jpeg, "image/jpeg"); + expect(out.includes(Buffer.from("Exif\0\0"))).toBe(false); + expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false); + expect(out.includes(Buffer.from("private comment"))).toBe(false); + }); + + it("keeps JFIF (APP0), ICC (APP2), the quantisation table, and the scan data", () => { + const out = stripImageMetadata(jpeg, "image/jpeg"); + expect(out.subarray(0, 2)).toEqual(soi); + expect(out.includes(Buffer.from("JFIF"))).toBe(true); + expect(out.includes(Buffer.from("ICC_PROFILE"))).toBe(true); + // The DQT segment and the entropy-coded scan tail must survive verbatim. + expect(out.includes(dqt)).toBe(true); + expect(out.includes(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]))).toBe(true); + // Output is strictly smaller (metadata removed). + expect(out.length).toBeLessThan(jpeg.length); + }); +}); + +describe("stripImageMetadata — PNG", () => { + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + function pngChunk(type: string, data: Buffer): Buffer { + return Buffer.concat([ + u32be(data.length), + Buffer.from(type, "ascii"), + data, + u32be(0), + ]); + } + const ihdr = pngChunk( + "IHDR", + Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]), + ); + const exif = pngChunk("eXIf", Buffer.from("GPSInfo private-location")); + const text = pngChunk("tEXt", Buffer.from("Comment\0secret note")); + const itxt = pngChunk( + "iTXt", + Buffer.from("XML:com.adobe.xmp\0\0\0\0\0"), + ); + const gama = pngChunk("gAMA", Buffer.from([0, 0, 0x8a, 0x3d])); + const idat = pngChunk("IDAT", Buffer.from([0x78, 0x9c, 0x01, 0x00])); + const iend = pngChunk("IEND", Buffer.alloc(0)); + const png = Buffer.concat([sig, ihdr, exif, text, itxt, gama, idat, iend]); + + it("removes eXIf, tEXt, and iTXt (XMP) chunks", () => { + const out = stripImageMetadata(png, "image/png"); + expect(out.includes(Buffer.from("GPSInfo"))).toBe(false); + expect(out.includes(Buffer.from("secret note"))).toBe(false); + expect(out.includes(Buffer.from("xmpmeta"))).toBe(false); + }); + + it("keeps IHDR, gAMA, IDAT, IEND and the signature", () => { + const out = stripImageMetadata(png, "image/png"); + expect(out.subarray(0, 8)).toEqual(sig); + expect(out.includes(gama)).toBe(true); + expect(out.includes(idat)).toBe(true); + expect(out.includes(iend)).toBe(true); + expect(out.length).toBeLessThan(png.length); + }); +}); + +describe("stripImageMetadata — WebP", () => { + function webpChunk(fourcc: string, data: Buffer): Buffer { + const padded = + data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data; + return Buffer.concat([ + Buffer.from(fourcc, "ascii"), + u32le(data.length), + padded, + ]); + } + // VP8X with EXIF (0x08) + XMP (0x04) flags set, an image chunk, plus EXIF/XMP. + const vp8xFlags = Buffer.from([0x08 | 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + const vp8x = webpChunk("VP8X", vp8xFlags); + const vp8 = webpChunk("VP8 ", Buffer.from([0x11, 0x22, 0x33, 0x44])); + const exif = webpChunk("EXIF", Buffer.from("GPSLocation private")); + const xmp = webpChunk("XMP ", Buffer.from("secret")); + const body = Buffer.concat([vp8x, vp8, exif, xmp]); + const header = Buffer.concat([ + Buffer.from("RIFF", "ascii"), + u32le(4 + body.length), + Buffer.from("WEBP", "ascii"), + ]); + const webp = Buffer.concat([header, body]); + + it("removes the EXIF and XMP chunks", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.includes(Buffer.from("GPSLocation"))).toBe(false); + expect(out.includes(Buffer.from("xmpmeta"))).toBe(false); + }); + + it("keeps the image chunk and clears the VP8X EXIF/XMP flag bits", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.includes(Buffer.from([0x11, 0x22, 0x33, 0x44]))).toBe(true); + // Locate VP8X and check its flag byte: EXIF/XMP bits cleared. + const vp8xIdx = out.indexOf(Buffer.from("VP8X", "ascii")); + expect(vp8xIdx).toBeGreaterThan(0); + const flags = out[vp8xIdx + 8]; + expect(flags & 0x08).toBe(0); + expect(flags & 0x04).toBe(0); + }); + + it("rewrites the RIFF size header to match the trimmed body", () => { + const out = stripImageMetadata(webp, "image/webp"); + expect(out.toString("ascii", 0, 4)).toBe("RIFF"); + expect(out.toString("ascii", 8, 12)).toBe("WEBP"); + // Declared size == 4 (for "WEBP") + the remaining chunk bytes. + expect(out.readUInt32LE(4)).toBe(out.length - 8); + }); +}); 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 b69c60843..3ea7f445e 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -16,12 +16,21 @@ vi.mock("@/lib/doctor-report-data", () => ({ vi.mock("@/lib/validations/doctor-report-prefs", () => ({ parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}), })); +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLinkDocument: { findMany: vi.fn() }, + }, +})); import { loadShareViewData } from "../share-view-data"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { prisma } from "@/lib/db"; import type { ShareContext } from "../resolve-share-token"; const collect = collectDoctorReportData as ReturnType; +const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType< + typeof vi.fn +>; function ctx(overrides: Partial = {}): ShareContext { return { @@ -44,6 +53,7 @@ describe("loadShareViewData — KVNR default OFF", () => { collect.mockResolvedValue({ patient: { displayName: "Shared record" }, }); + findDocs.mockResolvedValue([]); }); it("scopes the aggregator to the OWNER from the share context, never the wire", async () => { @@ -71,6 +81,63 @@ describe("loadShareViewData — KVNR default OFF", () => { expect(patient).not.toHaveProperty("kvnr"); }); + it("lists the frozen document set as metadata only (never bytes)", 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", + }, + }, + { + document: { + id: "doc-b", + title: null, + kind: "OTHER", + documentDate: null, + byteSize: 6789, + mimeType: "application/msword", + }, + }, + ]); + + const { documents } = await loadShareViewData(ctx()); + + // Scoped to THIS link + owner + live rows; the blob column is never named. + const arg = findDocs.mock.calls[0]![0] as { + where: Record; + select: { document: { select: Record } }; + }; + expect(arg.where).toEqual({ + shareLinkId: "link-1", + document: { userId: "owner-1", deletedAt: null }, + }); + expect(arg.select.document.select).not.toHaveProperty("contentEncrypted"); + + expect(documents).toEqual([ + { + id: "doc-a", + title: "Blood panel", + kind: "LAB_REPORT", + documentDate: "2026-01-15", + byteSize: 12345, + servingClass: "inline", + }, + { + id: "doc-b", + title: null, + kind: "OTHER", + documentDate: null, + byteSize: 6789, + servingClass: "attachment", + }, + ]); + }); + it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => { const now = Date.now(); await loadShareViewData(ctx({ rangeEnd: null })); diff --git a/src/lib/clinician-share/exif-strip.ts b/src/lib/clinician-share/exif-strip.ts new file mode 100644 index 000000000..15c8a174e --- /dev/null +++ b/src/lib/clinician-share/exif-strip.ts @@ -0,0 +1,228 @@ +/** + * v1.28 (document vault, Phase 3) — dependency-free metadata stripper for the + * share egress boundary (P3-D4). + * + * A recipient of a shared document should not inadvertently receive the + * owner's camera EXIF / GPS / XMP metadata. This module removes those markers + * from the common camera-photo raster formats (JPEG, PNG, WebP) as the bytes + * are served through the share route — NEVER at rest. The owner's stored + * original is the artefact and is untouched (v1 §6.5): the strip is a pure + * transform applied to a decrypted copy on its way out. + * + * Pure JS, no `sharp` / `libvips` / native decode (repo guardrail G6). We do + * not decode or re-encode pixels: we walk the container's segment/chunk + * structure and drop exactly the metadata carriers, copying every image-data + * segment through verbatim. Output renders identically; only the metadata is + * gone. + * + * Honest limits (documented in the operator docs + the share copy): PDF, TIFF, + * HEIC/HEIF, and Office formats are out of this stripper's reach and pass + * through unchanged. Those leave the origin as attachment-class downloads. + * Any format we don't recognise — or any parse that runs off the end of the + * buffer — is returned byte-for-byte unchanged (fail-safe: never corrupt a + * document we can't confidently rewrite). + */ +import { Buffer } from "node:buffer"; + +/** MIME types this stripper actively rewrites. Everything else passes through. */ +const STRIPPABLE_MIMES: ReadonlySet = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +/** + * Strip EXIF/XMP/GPS metadata from a raster image's bytes, dispatched by the + * document's stored (magic-byte-sniffed) MIME type. Returns the original + * buffer unchanged for any non-strippable type, and — fail-safe — for any + * input that does not parse cleanly as the claimed format. + */ +export function stripImageMetadata(bytes: Buffer, mimeType: string): Buffer { + if (!STRIPPABLE_MIMES.has(mimeType)) return bytes; + try { + switch (mimeType) { + case "image/jpeg": + return stripJpeg(bytes); + case "image/png": + return stripPng(bytes); + case "image/webp": + return stripWebp(bytes); + default: + return bytes; + } + } catch { + // Never corrupt a document because the stripper tripped: on any parse + // error, serve the original bytes. The serving-class posture (nosniff + + // true type) is the security boundary; the strip is a privacy nicety. + return bytes; + } +} + +// ─── JPEG ──────────────────────────────────────────────────────────────────── +// +// A JPEG is `FFD8` (SOI) followed by marker segments. Each non-entropy marker +// is `FF ` where `len` counts the two +// length bytes + payload. We drop the metadata-bearing APP segments and the +// comment segment, copy every other segment verbatim, and once we hit SOS +// (`FFDA`) copy the rest of the file (entropy-coded scan data) unchanged. +// +// Dropped: APP1 (`FFE1`, Exif + XMP), APP13 (`FFED`, Photoshop / IPTC), and +// COM (`FFFE`, free-text comment). Kept: APP0 (`FFE0`, JFIF — some decoders +// expect it) and APP2 (`FFE2`, ICC colour profile — dropping it would shift +// colours), plus every structural segment (quantisation/Huffman tables, frame +// headers, …). + +const JPEG_DROP_MARKERS: ReadonlySet = new Set([ + 0xe1, // APP1 — Exif, XMP + 0xed, // APP13 — Photoshop / IPTC-IIM + 0xfe, // COM — comment +]); + +function stripJpeg(bytes: Buffer): Buffer { + if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return bytes; // not a JPEG SOI — pass through + } + const out: Buffer[] = [bytes.subarray(0, 2)]; // SOI + let offset = 2; + while (offset + 1 < bytes.length) { + if (bytes[offset] !== 0xff) { + // Misaligned — bail and keep the remainder verbatim (fail-safe). + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + const marker = bytes[offset + 1]; + // Start Of Scan: everything after is entropy-coded image data. Copy it all. + if (marker === 0xda) { + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + // Standalone markers without a length payload (RSTn, TEM) — copy the 2 + // marker bytes and continue. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + out.push(bytes.subarray(offset, offset + 2)); + offset += 2; + continue; + } + // Length-prefixed segment. + if (offset + 4 > bytes.length) { + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + const segLen = bytes.readUInt16BE(offset + 2); + const segEnd = offset + 2 + segLen; + if (segLen < 2 || segEnd > bytes.length) { + // Corrupt length — keep the remainder verbatim rather than lose data. + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + if (!JPEG_DROP_MARKERS.has(marker)) { + out.push(bytes.subarray(offset, segEnd)); + } + offset = segEnd; + } + return Buffer.concat(out); +} + +// ─── PNG ───────────────────────────────────────────────────────────────────── +// +// An 8-byte signature followed by `length(4 BE) type(4 ascii) data crc(4)` +// chunks. We drop the ancillary metadata chunks and copy everything else +// (IHDR, PLTE, IDAT, IEND, gAMA, cHRM, iCCP, sRGB, …) verbatim. +// +// Dropped: `eXIf` (EXIF), `tEXt` / `zTXt` / `iTXt` (text incl. XMP), `tIME` +// (last-modified timestamp). + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const PNG_DROP_CHUNKS: ReadonlySet = new Set([ + "eXIf", + "tEXt", + "zTXt", + "iTXt", + "tIME", +]); + +function stripPng(bytes: Buffer): Buffer { + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) { + return bytes; + } + const out: Buffer[] = [bytes.subarray(0, 8)]; + let offset = 8; + while (offset + 8 <= bytes.length) { + const dataLen = bytes.readUInt32BE(offset); + const type = bytes.toString("ascii", offset + 4, offset + 8); + const chunkEnd = offset + 12 + dataLen; // len + type + data + crc + if (chunkEnd > bytes.length) { + // Truncated chunk — keep the remainder verbatim (fail-safe). + out.push(bytes.subarray(offset)); + return Buffer.concat(out); + } + if (!PNG_DROP_CHUNKS.has(type)) { + out.push(bytes.subarray(offset, chunkEnd)); + } + offset = chunkEnd; + if (type === "IEND") break; // nothing legitimate follows IEND + } + return Buffer.concat(out); +} + +// ─── WebP ──────────────────────────────────────────────────────────────────── +// +// A RIFF container: `"RIFF" "WEBP"` then FourCC chunks +// (` `, payload padded to an even length). +// We drop the `EXIF` and `XMP ` chunks and, when the extended header `VP8X` is +// present, clear its EXIF/XMP presence flag bits so the rewritten file is +// self-consistent. The image-data chunks (VP8/VP8L/VP8X/ALPH/ANIM/ANMF) copy +// through verbatim. + +function stripWebp(bytes: Buffer): Buffer { + if ( + bytes.length < 12 || + bytes.toString("ascii", 0, 4) !== "RIFF" || + bytes.toString("ascii", 8, 12) !== "WEBP" + ) { + return bytes; + } + const kept: Buffer[] = []; + let offset = 12; + while (offset + 8 <= bytes.length) { + const fourcc = bytes.toString("ascii", offset, offset + 4); + const chunkSize = bytes.readUInt32LE(offset + 4); + const padded = chunkSize + (chunkSize % 2); // chunks pad to even length + const payloadStart = offset + 8; + const chunkEnd = payloadStart + padded; + if (chunkEnd > bytes.length) { + // Truncated — keep the remainder verbatim (fail-safe). + kept.push(bytes.subarray(offset)); + offset = bytes.length; + break; + } + if (fourcc === "EXIF" || fourcc === "XMP ") { + // Drop the metadata chunk entirely. + offset = chunkEnd; + continue; + } + if (fourcc === "VP8X") { + // Extended-format header: byte 0 of the payload carries the feature + // flags. Clear the EXIF (0x08) and XMP (0x04) presence bits so the + // header no longer advertises metadata we just removed. + const chunk = Buffer.from(bytes.subarray(offset, chunkEnd)); + chunk[8] = chunk[8] & ~0x08 & ~0x04; + kept.push(chunk); + offset = chunkEnd; + continue; + } + kept.push(bytes.subarray(offset, chunkEnd)); + offset = chunkEnd; + } + // Rebuild the RIFF header with the recomputed payload size (the sum of the + // kept chunks plus the trailing "WEBP" FourCC). + const body = Buffer.concat(kept); + const header = Buffer.alloc(12); + header.write("RIFF", 0, "ascii"); + header.writeUInt32LE(4 + body.length, 4); // "WEBP" + chunk bytes + header.write("WEBP", 8, "ascii"); + return Buffer.concat([header, body]); +} diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 6ecaba023..1a5285250 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -12,19 +12,40 @@ * them under an explicit "not a clinical assessment" card), but they are read * straight from the aggregator — no AI call, no coach, no insight generation. */ +import { prisma } from "@/lib/db"; import { collectDoctorReportData, type DoctorReportData, type DoctorReportRange, } 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 type { ShareContext } from "@/lib/clinician-share/resolve-share-token"; +/** + * v1.28 — metadata for one document on the share's frozen set. NEVER carries + * bytes: the share serve route (`/c//d/`) is the only decrypt path + * (P3-D5). The recipient view renders this list and points each entry at that + * route (Class A inline preview / Class B download). + */ +export interface ShareViewDocument { + id: string; + title: string | null; + kind: string; + /** Filing date (YYYY-MM-DD at UTC) or null. */ + documentDate: string | null; + byteSize: number; + servingClass: DocumentServingClass; +} + export interface ShareViewData { /** The aggregated, owner-scoped report payload over the frozen window. */ report: DoctorReportData; /** The resolved section toggles (mood opt-in, defaults otherwise). */ sections: ReturnType; + /** v1.28 — the hand-picked documents on this link (metadata only). */ + documents: ShareViewDocument[]; } /** @@ -53,9 +74,51 @@ export async function loadShareViewData( const sections = parseDoctorReportPrefs(context.sectionsJson); const range = frozenRange(context); - const report = await collectDoctorReportData(context.ownerUserId, range, { - sections, + const [report, documents] = await Promise.all([ + collectDoctorReportData(context.ownerUserId, range, { sections }), + loadShareDocuments(context), + ]); + + return { report, sections, documents }; +} + +/** + * The frozen document set for a resolved share, as metadata only. Scoped to + * the link's membership rows AND the owner (defence in depth) AND live rows — + * a document the owner soft-deleted after sharing drops out of the list, just + * as it 404s at the serve route. The blob column is never selected. + */ +async function loadShareDocuments( + context: ShareContext, +): Promise { + const rows = await prisma.clinicianShareLinkDocument.findMany({ + where: { + shareLinkId: context.shareLinkId, + document: { userId: context.ownerUserId, deletedAt: null }, + }, + select: { + document: { + select: { + id: true, + title: true, + kind: true, + documentDate: true, + byteSize: true, + mimeType: true, + }, + }, + }, + orderBy: { createdAt: "asc" }, }); - return { report, sections }; + return rows.map(({ document }) => ({ + id: document.id, + title: document.title, + kind: document.kind, + documentDate: document.documentDate + ? document.documentDate.toISOString().slice(0, 10) + : null, + byteSize: document.byteSize, + servingClass: servingClassFor(document.mimeType), + })); } diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 048f52f1c..57137b7b8 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -25,6 +25,9 @@ const shareLinkSummary = z.object({ // v1.18.7 — whether a passphrase second factor guards this link. Always true // for links created on v1.18.7+; false only for legacy links. protected: z.boolean(), + // v1.28 — how many documents this share carries. Never the ids, never the + // bytes; the share serve route is the only decrypt path. + documentCount: z.number(), expiresAt: z.string(), createdAt: z.string(), revokedAt: z.string().nullable(), @@ -103,7 +106,7 @@ export const healthRecordPaths: NonNullable = { tags: ["Export"], summary: "Create a clinician share link (v1.11.0)", description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", requestBody: { required: true, content: { @@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/c/{token}/d/{id}": { + get: { + tags: ["Export"], + summary: "Serve a shared document blob (v1.28, public)", + description: + "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + id: z.string().describe("A document id on the link's frozen set."), + }), + }, + responses: { + "200": { + description: + "The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` for Class B (attachment).", + content: { + "application/octet-stream": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the frozen set).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link serve rate limit exceeded.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"], diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index 01b9b8467..41fe80d54 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -33,10 +33,22 @@ export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES); const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000; +/** + * v1.28 — the most documents a single share may carry. A share is a hand-picked + * handover, not a bulk dump; the cap bounds the create-time ownership check and + * the public view's per-blob fetch fan-out. The ids are validated as the + * caller's own LIVE documents in the route (a schema can only bound the shape). + */ +export const SHARE_LINK_MAX_DOCUMENTS = 50; + /** * Create payload. `expiresAt` is an absolute ISO instant, must be in the * future, and at most `SHARE_LINK_MAX_DAYS` ahead of now. `rangeStart` / * `rangeEnd` are the frozen reporting window (rangeEnd null = rolling). + * `documentIds` (optional) is the hand-picked document set, frozen write-once + * at create; each id is ownership-checked in the route before any row is + * written. A documents-only share (empty report sections + a non-empty + * `documentIds`) is valid. */ export const createShareLinkSchema = z .object({ @@ -46,6 +58,10 @@ export const createShareLinkSchema = z sections: exportSectionsSchema.optional(), resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(), allowFhirApi: z.boolean().optional(), + documentIds: z + .array(z.string().trim().min(1).max(64)) + .max(SHARE_LINK_MAX_DOCUMENTS) + .optional(), expiresAt: z.iso .datetime({ offset: true }) .refine((v) => new Date(v).getTime() > Date.now(), { diff --git a/src/proxy.ts b/src/proxy.ts index 91b71335b..07639ca09 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -313,12 +313,23 @@ export function proxy(request: NextRequest) { const isDocumentServeRoute = /^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname); + // v1.28 — the public share-scoped document serve route at + // `/c//d/` is the share analogue of the owner `/original` route: + // it decrypts and serves a user-uploaded document that the clinician view + // (`/c/`) frames same-origin (an