diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index 980fa8391..84520733b 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -104,6 +104,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-1", filename: "report.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); @@ -130,6 +131,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-2", filename: null, mimeType: "image/png", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")), } as never); @@ -147,6 +149,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-4", filename: "Müller-Bericht.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); @@ -176,6 +179,7 @@ describe("GET /api/documents/inbound/[id]/original", () => { id: "doc-3", filename: "report.pdf", mimeType: "application/pdf", + contentCodec: "base64v1", contentEncrypted: encryptDocumentToBytes(PDF_BYTES), } as never); decryptMock.mockImplementation(() => { @@ -189,3 +193,66 @@ describe("GET /api/documents/inbound/[id]/original", () => { expect(body.error).toBeTruthy(); }); }); + +describe("serving-posture header matrix (Class A inline vs Class B attachment)", () => { + function seed(mimeType: string, filename: string) { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-m", + filename, + mimeType, + contentCodec: "base64v1", + contentEncrypted: encryptDocumentToBytes(Buffer.from("payload")), + } as never); + } + + it.each([ + ["application/pdf", "scan.pdf"], + ["image/jpeg", "photo.jpg"], + ["image/png", "scan.png"], + ["image/webp", "scan.webp"], + ["image/gif", "anim.gif"], + ])( + "Class A %s → inline, true type, nosniff, CSP sandbox", + async (mime, name) => { + seed(mime, name); + const res = await callGet(makeReq("doc-m"), ctx("doc-m")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe(mime); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("Content-Security-Policy")).toBe("sandbox"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }, + ); + + it.each([ + [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "letter.docx", + ], + ["application/msword", "letter.doc"], + ["text/plain", "befund.txt"], + ["text/csv", "werte.csv"], + ["application/rtf", "note.rtf"], + ["image/tiff", "scan.tif"], + ["image/heic", "photo.heic"], + ["application/xml", "export.xml"], + ["application/json", "export.json"], + ])( + "Class B %s → attachment + octet-stream, never inline", + async (mime, name) => { + seed(mime, name); + const res = await callGet(makeReq("doc-m"), ctx("doc-m")); + expect(res.status).toBe(200); + // The true stored type is deliberately NOT surfaced on the wire. + 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(name); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + // No render context → no sandbox header needed on a pure download. + expect(res.headers.get("Content-Security-Policy")).toBeNull(); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + }, + ); +}); diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts index 97366a357..2acf5e2b4 100644 --- a/src/app/api/documents/inbound/[id]/original/route.ts +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -1,23 +1,31 @@ /** - * v1.25.1 (W-DOCS-IN) — view / download the original uploaded document. + * Document vault: view / download the original uploaded document. * - * The raw doctor report / discharge letter is stored encrypted at rest - * (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY - * path that decrypts and serves it, so the user can re-read the source the - * extracted facts came from. Owner-scoped + module-gated; the bytes are PHI: - * never logged, never served cross-user, fail-closed on a decrypt error. + * The raw document is stored encrypted at rest + * (`InboundDocument.contentEncrypted`, AES-256-GCM, codec per row). This + * route is the ONLY path that decrypts and serves it. Owner-scoped + + * module-gated; the bytes are PHI: never logged, never served cross-user, + * fail-closed on a decrypt error. * - * The response carries the stored `mimeType`. Uploads are constrained at the - * store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte - * sniffed), so the document is always served `Content-Disposition: inline` for - * in-tab rendering. The filename is sanitised before it reaches the header. + * Serving posture IS the security boundary (upload policy §N1): the serving + * class derives from the stored MIME type via `servingClassFor` — + * + * - inline (Class A: PDF/JPEG/PNG/WebP/GIF): true Content-Type, + * `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`, + * `Cache-Control: private, no-store`, plus `Content-Security-Policy: + * sandbox` as defence-in-depth for the PDF viewer context. + * - attachment (Class B: Office/text/TIFF/HEIC/XML/JSON): ALWAYS + * `Content-Disposition: attachment` with `Content-Type: + * application/octet-stream` + `nosniff` — never inline, regardless of + * the stored type, so a misclassified file cannot execute in-origin. */ import { NextResponse } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; -import { decryptDocumentFromBytes } from "@/lib/documents/store"; +import { decryptDocumentContent } from "@/lib/documents/store"; +import { servingClassFor } from "@/lib/documents/upload-policy"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -40,6 +48,22 @@ const MIME_EXTENSION: Record = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", + "image/gif": "gif", + "image/tiff": "tif", + "image/heic": "heic", + "application/msword": "doc", + "application/vnd.ms-excel": "xls", + "application/vnd.ms-powerpoint": "ppt", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + "docx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": + "pptx", + "text/plain": "txt", + "text/csv": "csv", + "application/rtf": "rtf", + "application/xml": "xml", + "application/json": "json", }; /** @@ -109,6 +133,7 @@ export const GET = apiHandler( filename: true, mimeType: true, contentEncrypted: true, + contentCodec: true, }, }); if (!document) { @@ -119,10 +144,14 @@ export const GET = apiHandler( let bytes: Buffer; try { - bytes = decryptDocumentFromBytes(document.contentEncrypted); + bytes = decryptDocumentContent( + document.contentEncrypted, + document.contentCodec, + ); } catch { // Fail closed — never fall back to the raw ciphertext. The error reason - // (bad / missing key id) is logged by the annotation, never the bytes. + // (bad / missing key id, unknown codec) is logged by the annotation, + // never the bytes. annotate({ action: { name: "documents.inbound.original.decryptFailed" }, meta: { documentId: document.id }, @@ -132,16 +161,18 @@ export const GET = apiHandler( }); } + const servingClass = servingClassFor(document.mimeType); const downloadName = safeDownloadName( document.filename, document.id, document.mimeType, ); - // ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus - // an RFC 5987 `filename*` that carries the real UTF-8 name to compliant - // clients. The stored set is inline-safe by construction, so always inline. + // ASCII fallback for the bare `filename=` (replace any non-ASCII byte) + // plus an RFC 5987 `filename*` that carries the real UTF-8 name to + // compliant clients. const asciiName = downloadName.replace(/[^ -~]/g, "_"); - const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`; + const dispositionType = servingClass === "inline" ? "inline" : "attachment"; + const disposition = `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`; annotate({ action: { name: "documents.inbound.original.get" }, @@ -149,19 +180,33 @@ export const GET = apiHandler( documentId: document.id, mimeType: document.mimeType, bytes: bytes.byteLength, - disposition: "inline", + disposition: dispositionType, }, }); + const headers: Record = { + // Class B leaves the origin ONLY as an opaque download — the true + // stored type is deliberately not surfaced on the wire. + "Content-Type": + servingClass === "inline" + ? document.mimeType + : "application/octet-stream", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": disposition, + // The disposition/type pair is authoritative — never MIME-sniffed. + "X-Content-Type-Options": "nosniff", + // Private PHI — never cache in a shared / disk cache. + "Cache-Control": "private, no-store", + }; + if (servingClass === "inline") { + // Defence-in-depth for the in-origin render context (notably the PDF + // viewer): no scripts, no forms, no top-navigation from the response. + headers["Content-Security-Policy"] = "sandbox"; + } + return new NextResponse(bytes as unknown as BodyInit, { status: 200, - headers: { - "Content-Type": document.mimeType, - "Content-Length": String(bytes.byteLength), - "Content-Disposition": disposition, - // Private PHI — never cache in a shared / disk cache. - "Cache-Control": "private, no-store", - }, + headers, }); }, ); diff --git a/src/app/api/documents/inbound/[id]/restore/route.ts b/src/app/api/documents/inbound/[id]/restore/route.ts new file mode 100644 index 000000000..1d43a8c60 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/restore/route.ts @@ -0,0 +1,155 @@ +/** + * Document vault: restore a soft-deleted (tombstoned) document. + * + * Delete is undo-able: the DELETE route tombstones (`deletedAt`), the UI + * shows an "Undo" toast that calls this route, and the daily purge job + * physically removes tombstones older than the 30-day grace window. This + * route clears the tombstone; it answers 409 when the row is gone (purged — + * indistinguishable from never-existed by construction, and owner-scoped + * either way) or when restoring would collide with a live duplicate of the + * same bytes (the `inbound_documents_user_sha_live` partial unique index). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { loadConditionLinks } from "@/lib/documents/links"; +import { serialiseDocumentDetail } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { isP2002 } from "@/lib/prisma-errors"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import type { InboundDocumentStatus } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +/** Shares the write posture of the other owner-scoped document mutations. */ +const WRITE_LIMIT_PER_HOUR = 240; +const WRITE_WINDOW_MS = 60 * 60 * 1000; + +/** + * Post-restore lifecycle status, derived from the staged facts: no facts → + * STORED, any PENDING → EXTRACTED, otherwise every fact was acted on → + * CONFIRMED. (DELETE stamped DISCARDED; the prior status is not persisted.) + */ +function restoredStatus( + facts: Array<{ status: string }>, +): InboundDocumentStatus { + if (facts.length === 0) return "STORED"; + return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED"; +} + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-write:${user.id}`, + WRITE_LIMIT_PER_HOUR, + WRITE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { id } = await params; + const row = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id }, + omit: { contentEncrypted: true }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + + if (!row) { + // Purged (past the 30-day grace) or never existed — the tombstone is + // gone either way, so the undo window is over: 409, not 404. + return apiError("This document can no longer be restored.", 409, { + errorCode: "documents.inbound.restoreGone", + reason: "purged", + }); + } + + if (row.deletedAt === null) { + // Already live — restoring twice is a no-op, not an error (the undo + // toast may fire after a concurrent restore). + const links = await loadConditionLinks(user.id, [row.id]); + return apiSuccess( + serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []), + ); + } + + // A live duplicate of the same bytes blocks the restore (the partial + // unique index would reject it; check first for a clean 409). + if (row.contentSha256) { + const conflict = await prisma.inboundDocument.findFirst({ + where: { + userId: user.id, + contentSha256: row.contentSha256, + deletedAt: null, + id: { not: row.id }, + }, + select: { id: true }, + }); + if (conflict) { + return apiError("An identical document is already stored.", 409, { + errorCode: "documents.inbound.restoreDuplicate", + reason: "duplicateExists", + existingId: conflict.id, + }); + } + } + + try { + await prisma.inboundDocument.update({ + where: { id: row.id }, + data: { deletedAt: null, status: restoredStatus(row.facts) }, + }); + } catch (err) { + if (isP2002(err)) { + // Raced a concurrent identical upload into the partial unique index. + return apiError("An identical document is already stored.", 409, { + errorCode: "documents.inbound.restoreDuplicate", + reason: "duplicateExists", + }); + } + throw err; + } + + const restored = await prisma.inboundDocument.findFirstOrThrow({ + where: { id: row.id }, + omit: { contentEncrypted: true }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + const links = await loadConditionLinks(user.id, [restored.id]); + + await auditLog("documents.inbound.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { documentId: restored.id }, + }); + + annotate({ + action: { name: "documents.vault.restore" }, + meta: { documentId: restored.id }, + }); + + return apiSuccess( + serialiseDocumentDetail( + restored, + restored.facts, + links.get(restored.id) ?? [], + ), + ); + }, +); diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts new file mode 100644 index 000000000..0a9fe10b9 --- /dev/null +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -0,0 +1,203 @@ +/** + * Document vault: bulk actions over up to 100 owner-scoped documents. + * + * `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions: + * `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition + * links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A + * partial failure never aborts the batch — each id reports `ok` or a short + * machine reason. Every id is narrowed to the caller; a foreign id reads as + * "notFound", exactly like the single-document routes. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { isP2002 } from "@/lib/prisma-errors"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + documentBulkSchema, + type DocumentBulkResultDto, +} from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +/** + * One bulk call covers up to 100 rows, so the bucket is tighter than the + * single-row write bucket (240/h) while allowing the same overall throughput. + */ +const BULK_LIMIT_PER_HOUR = 60; +const BULK_WINDOW_MS = 60 * 60 * 1000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-bulk:${user.id}`, + BULK_LIMIT_PER_HOUR, + BULK_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = documentBulkSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + const { ids, action, kind, episodeId } = parsed.data; + const uniqueIds = [...new Set(ids)]; + + // Episode actions: the target episode must be a LIVE episode of the caller. + if (action === "linkEpisode" || action === "unlinkEpisode") { + const episode = await prisma.illnessEpisode.findFirst({ + where: { id: episodeId!, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!episode) { + return apiError("Episode not found", 404, { + errorCode: "documents.inbound.episodeNotFound", + }); + } + } + + // One owner-scoped sweep resolves every id's current state; per-id work + // then never touches a row the caller does not own. + const owned = await prisma.inboundDocument.findMany({ + where: { id: { in: uniqueIds }, userId: user.id }, + select: { id: true, deletedAt: true, contentSha256: true }, + }); + const byId = new Map(owned.map((d) => [d.id, d])); + + const results: DocumentBulkResultDto[] = []; + for (const id of uniqueIds) { + const row = byId.get(id); + if (!row) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + try { + switch (action) { + case "setKind": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await prisma.inboundDocument.update({ + where: { id }, + data: { kind: kind! }, + }); + break; + } + case "linkEpisode": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await prisma.documentConditionLink.createMany({ + data: [{ documentId: id, episodeId: episodeId!, userId: user.id }], + skipDuplicates: true, + }); + break; + } + case "unlinkEpisode": { + await prisma.documentConditionLink.deleteMany({ + where: { documentId: id, episodeId: episodeId!, userId: user.id }, + }); + break; + } + case "delete": { + if (row.deletedAt !== null) { + // Already tombstoned — deleting twice is a no-op success. + break; + } + await prisma.inboundDocument.update({ + where: { id }, + data: { deletedAt: new Date(), status: "DISCARDED" }, + }); + break; + } + case "restore": { + if (row.deletedAt === null) { + // Already live — restoring twice is a no-op success. + break; + } + if (row.contentSha256) { + const conflict = await prisma.inboundDocument.findFirst({ + where: { + userId: user.id, + contentSha256: row.contentSha256, + deletedAt: null, + id: { not: id }, + }, + select: { id: true }, + }); + if (conflict) { + results.push({ id, ok: false, error: "conflict" }); + continue; + } + } + const facts = await prisma.extractedFact.findMany({ + where: { documentId: id, userId: user.id }, + select: { status: true }, + }); + const status = + facts.length === 0 + ? "STORED" + : facts.some((f) => f.status === "PENDING") + ? "EXTRACTED" + : "CONFIRMED"; + await prisma.inboundDocument.update({ + where: { id }, + data: { deletedAt: null, status }, + }); + break; + } + } + results.push({ id, ok: true, error: null }); + } catch (err) { + if (isP2002(err)) { + results.push({ id, ok: false, error: "conflict" }); + continue; + } + throw err; + } + } + + const okCount = results.filter((r) => r.ok).length; + + await auditLog("documents.inbound.bulk", { + userId: user.id, + ipAddress: getClientIp(request), + details: { action, requested: uniqueIds.length, succeeded: okCount }, + }); + + annotate({ + action: { name: "documents.vault.bulk" }, + meta: { bulkAction: action, requested: uniqueIds.length, ok: okCount }, + }); + + return apiSuccess({ results }); +}); diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts new file mode 100644 index 000000000..bdaf44be9 --- /dev/null +++ b/src/app/api/documents/inbound/usage/route.ts @@ -0,0 +1,50 @@ +/** + * Document vault: storage usage + effective limits for the calling user. + * + * The UI reads this before offering an upload (quota bar above 80 % usage, + * client-side pre-flight against `maxFileBytes`, picker `accept` list from + * `acceptedExtensions`). `usedBytes` counts every non-purged row — tombstones + * still hold TOAST bytes until the purge job reclaims them, so "deleted" + * bytes are never invisible weight and an undo never changes usage. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { + DOCUMENT_ACCEPTED_EXTENSIONS, + resolveDocumentLimits, +} from "@/lib/documents/upload-policy"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const [limits, rows] = await Promise.all([ + resolveDocumentLimits(user.id), + prisma.$queryRaw>` + SELECT COALESCE(SUM(byte_size), 0)::bigint AS used + FROM inbound_documents + WHERE user_id = ${user.id} + `, + ]); + const usedBytes = Number(rows[0]?.used ?? 0); + + annotate({ + action: { name: "documents.vault.usage" }, + meta: { usedBytes, quotaBytes: limits.quotaBytes }, + }); + + const payload: DocumentUsageDto = { + usedBytes, + quotaBytes: limits.quotaBytes, + maxFileBytes: limits.maxFileBytes, + acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS], + }; + return apiSuccess(payload); +});