diff --git a/src/lib/__tests__/crypto-bytes.test.ts b/src/lib/__tests__/crypto-bytes.test.ts
new file mode 100644
index 000000000..196b10e71
Binary files /dev/null and b/src/lib/__tests__/crypto-bytes.test.ts differ
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
index 3d67e2639..bd2e015f3 100644
--- a/src/lib/crypto.ts
+++ b/src/lib/crypto.ts
@@ -264,6 +264,105 @@ export function reencryptToActive(encoded: string): string {
return encrypt(decrypt(encoded));
}
+// ─── Binary codec ("binary2") ────────────────────────────────────────────────
+//
+// AES-256-GCM over raw bytes, skipping the base64 detour the string codec
+// takes (−33 % at rest, one fewer full copy in memory). Written for large
+// binary payloads (the document vault's `contentEncrypted`); the consumer
+// records which codec a row uses in an explicit column — the layout is never
+// header-sniffed against the string codec.
+//
+// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext]
+//
+// Same versioned-key discipline as the string codec: encrypt always writes
+// the active key id, decrypt is fail-closed on an unknown version, a
+// malformed header, or an unconfigured key id.
+
+/** Version byte identifying the binary2 layout. */
+const BYTES_CODEC_VERSION = 0x02;
+
+/** Encrypt raw bytes with the active key into the binary2 layout. */
+export function encryptBytes(plaintext: Buffer): Buffer {
+ const { id, key } = getActiveKey();
+ const keyId = Buffer.from(id, "ascii");
+ if (keyId.byteLength < 1 || keyId.byteLength > 32) {
+ // loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces.
+ throw new Error(`Encryption key id '${id}' is not header-encodable`);
+ }
+ const iv = randomBytes(IV_LENGTH);
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]);
+ return Buffer.concat([header, keyId, iv, tag, ct]);
+}
+
+/** Parse a binary2 header. Throws on any malformation (fail-closed). */
+function parseBytesHeader(payload: Buffer): {
+ keyId: string;
+ iv: Buffer;
+ tag: Buffer;
+ ct: Buffer;
+} {
+ if (payload.byteLength < 2) {
+ throw new Error("binary2 payload is truncated (no header)");
+ }
+ if (payload[0] !== BYTES_CODEC_VERSION) {
+ throw new Error(
+ `Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`,
+ );
+ }
+ const keyIdLen = payload[1];
+ if (keyIdLen < 1 || keyIdLen > 32) {
+ throw new Error("binary2 payload has an invalid key-id length");
+ }
+ const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH;
+ if (payload.byteLength < fixed) {
+ throw new Error("binary2 payload is truncated");
+ }
+ const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii");
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) {
+ throw new Error("binary2 payload carries a malformed key id");
+ }
+ const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH);
+ const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed);
+ const ct = payload.subarray(fixed);
+ return { keyId, iv, tag, ct };
+}
+
+/**
+ * Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown
+ * version, a malformed header, or an unconfigured key id throws — the caller
+ * must treat a throw as "cannot serve", never fall back to the ciphertext.
+ */
+export function decryptBytes(payload: Buffer): Buffer {
+ const { keyId, iv, tag, ct } = parseBytesHeader(payload);
+ const key = getKeyById(keyId);
+ if (!key) {
+ throw new Error(
+ `Encryption key id '${keyId}' is not configured. Add it to ` +
+ `ENCRYPTION_KEYS before decrypting rows written under that key.`,
+ );
+ }
+ const dec = createDecipheriv(ALGORITHM, key, iv);
+ dec.setAuthTag(tag);
+ return Buffer.concat([dec.update(ct), dec.final()]);
+}
+
+/** The key id a binary2 payload was written under, or null when unparsable. */
+export function extractKeyIdFromBytes(payload: Buffer): string | null {
+ try {
+ return parseBytesHeader(payload).keyId;
+ } catch {
+ return null;
+ }
+}
+
+/** Re-encrypt a binary2 payload with the active key. Used by rotation. */
+export function reencryptBytesToActive(payload: Buffer): Buffer {
+ return encryptBytes(decryptBytes(payload));
+}
+
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
export function extractKeyId(encoded: string): string | null {
const dot = encoded.indexOf(".");
diff --git a/src/lib/documents/__tests__/store-codec.test.ts b/src/lib/documents/__tests__/store-codec.test.ts
new file mode 100644
index 000000000..8b4017ba7
--- /dev/null
+++ b/src/lib/documents/__tests__/store-codec.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { Buffer } from "node:buffer";
+
+/**
+ * Document content codec dispatch: new uploads write "binary2", pre-vault
+ * rows keep decoding via the legacy "base64v1" string path, and an unknown
+ * codec value fails closed instead of guessing.
+ */
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ appSettings: { findUnique: vi.fn() },
+ user: { findUnique: vi.fn() },
+ },
+}));
+
+import { _resetCryptoCacheForTests } from "@/lib/crypto";
+import {
+ ACTIVE_DOCUMENT_CODEC,
+ decryptDocumentContent,
+ decryptDocumentFromBytes,
+ encryptDocumentContent,
+ encryptDocumentToBytes,
+} from "../store";
+
+const KEY_V1 =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+const SAMPLE = Buffer.concat([
+ Buffer.from("%PDF-1.7\n"),
+ Buffer.from([0x00, 0xff, 0x80, 0x7f]),
+ Buffer.alloc(2048, 0xab),
+]);
+
+beforeEach(() => {
+ vi.unstubAllEnvs();
+ vi.stubEnv("ENCRYPTION_KEYS", "");
+ vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
+ vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
+ _resetCryptoCacheForTests();
+});
+
+describe("document content codecs", () => {
+ it("writes new uploads with the binary2 codec and round-trips them", () => {
+ const { content, codec } = encryptDocumentContent(SAMPLE);
+ expect(codec).toBe("binary2");
+ expect(codec).toBe(ACTIVE_DOCUMENT_CODEC);
+ expect(Buffer.from(content).equals(SAMPLE)).toBe(false);
+ expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true);
+ });
+
+ it("still decodes legacy base64v1 rows byte-identically", () => {
+ // A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes.
+ const legacy = encryptDocumentToBytes(SAMPLE);
+ expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe(
+ true,
+ );
+ // The legacy helper itself stays the exact inverse.
+ expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true);
+ });
+
+ it("binary2 is ~25 % smaller at rest than the base64 detour", () => {
+ const legacy = encryptDocumentToBytes(SAMPLE);
+ const { content } = encryptDocumentContent(SAMPLE);
+ expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8);
+ });
+
+ it("fails closed on an unknown codec value", () => {
+ const { content } = encryptDocumentContent(SAMPLE);
+ expect(() => decryptDocumentContent(content, "codec9")).toThrow(
+ /unknown document content codec/i,
+ );
+ expect(() => decryptDocumentContent(content, "")).toThrow();
+ });
+
+ it("fails closed when codecs are crossed (no silent fallback)", () => {
+ const legacy = encryptDocumentToBytes(SAMPLE);
+ const { content: binary } = encryptDocumentContent(SAMPLE);
+ // Legacy bytes read as binary2 → header parse fails.
+ expect(() => decryptDocumentContent(legacy, "binary2")).toThrow();
+ // Binary bytes read as base64v1 → string decrypt fails.
+ expect(() => decryptDocumentContent(binary, "base64v1")).toThrow();
+ });
+});
diff --git a/src/lib/documents/__tests__/upload-policy.test.ts b/src/lib/documents/__tests__/upload-policy.test.ts
new file mode 100644
index 000000000..e83bd36fc
--- /dev/null
+++ b/src/lib/documents/__tests__/upload-policy.test.ts
@@ -0,0 +1,326 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Document-vault policy layer: exhaustive accept-matrix coverage.
+ *
+ * Every Class A and Class B format is detected from real magic-byte
+ * fixtures, every deny case is denied, the wire Content-Type never plays a
+ * role (the detector does not even receive it), container ambiguity (ZIP →
+ * OOXML only under an Office extension) is pinned, adversarial polyglots
+ * behave as designed, and the limits resolver clamps to the hard ceiling.
+ */
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ appSettings: { findUnique: vi.fn() },
+ user: { findUnique: vi.fn() },
+ },
+}));
+
+import { prisma } from "@/lib/db";
+import {
+ DOCUMENT_DEFAULT_MAX_FILE_BYTES,
+ DOCUMENT_DEFAULT_QUOTA_BYTES,
+ DOCUMENT_HARD_MAX_FILE_BYTES,
+ detectDocumentType,
+ resolveDocumentLimits,
+ servingClassFor,
+} from "../upload-policy";
+
+// ─── Magic-byte fixtures ─────────────────────────────────────────────────────
+
+const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1");
+const JPEG = Buffer.concat([
+ Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
+ Buffer.alloc(16, 0x11),
+]);
+const PNG = Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ Buffer.alloc(16, 0x22),
+]);
+const WEBP = Buffer.concat([
+ Buffer.from("RIFF"),
+ Buffer.from([0x24, 0x00, 0x00, 0x00]),
+ Buffer.from("WEBPVP8 "),
+ Buffer.alloc(8, 0x33),
+]);
+const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]);
+const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]);
+const ZIP = Buffer.concat([
+ Buffer.from([0x50, 0x4b, 0x03, 0x04]),
+ Buffer.alloc(32, 0x55),
+]);
+const CFB = Buffer.concat([
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
+ Buffer.alloc(32, 0x66),
+]);
+const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1");
+const TIFF_LE = Buffer.concat([
+ Buffer.from([0x49, 0x49, 0x2a, 0x00]),
+ Buffer.alloc(16, 0x77),
+]);
+const TIFF_BE = Buffer.concat([
+ Buffer.from([0x4d, 0x4d, 0x00, 0x2a]),
+ Buffer.alloc(16, 0x77),
+]);
+
+/** ISO-BMFF ftyp box with the given major + compatible brands. */
+function ftyp(major: string, compat: string[] = []): Buffer {
+ const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii");
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(8 + brands.byteLength);
+ return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]);
+}
+
+const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8");
+const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]);
+const ELF = Buffer.concat([
+ Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
+ Buffer.alloc(32, 0),
+]);
+const MACHO = Buffer.concat([
+ Buffer.from([0xcf, 0xfa, 0xed, 0xfe]),
+ Buffer.alloc(32, 0),
+]);
+
+describe("detectDocumentType — Class A (inline; magic bytes only)", () => {
+ it.each([
+ ["report.pdf", PDF, "application/pdf"],
+ ["photo.jpg", JPEG, "image/jpeg"],
+ ["scan.png", PNG, "image/png"],
+ ["scan.webp", WEBP, "image/webp"],
+ ["anim87.gif", GIF87, "image/gif"],
+ ["anim89.gif", GIF89, "image/gif"],
+ ])("%s → %s inline", (name, bytes, mime) => {
+ expect(detectDocumentType(bytes, name)).toEqual({
+ mimeType: mime,
+ servingClass: "inline",
+ });
+ });
+
+ it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => {
+ expect(detectDocumentType(JPEG, "export.dat")).toEqual({
+ mimeType: "image/jpeg",
+ servingClass: "inline",
+ });
+ });
+});
+
+describe("detectDocumentType — Class B (attachment-only)", () => {
+ it.each([
+ [
+ "letter.docx",
+ ZIP,
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ],
+ [
+ "values.xlsx",
+ ZIP,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ],
+ [
+ "slides.pptx",
+ ZIP,
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ],
+ ["letter.doc", CFB, "application/msword"],
+ ["values.xls", CFB, "application/vnd.ms-excel"],
+ ["slides.ppt", CFB, "application/vnd.ms-powerpoint"],
+ ["note.rtf", RTF, "application/rtf"],
+ ["scan.tif", TIFF_LE, "image/tiff"],
+ ["scan.tiff", TIFF_BE, "image/tiff"],
+ ["befund.txt", TEXT, "text/plain"],
+ ["befund.md", TEXT, "text/plain"],
+ ["werte.csv", TEXT, "text/csv"],
+ ["export.xml", TEXT, "application/xml"],
+ ["export.json", TEXT, "application/json"],
+ ])("%s → %s attachment", (name, bytes, mime) => {
+ expect(detectDocumentType(bytes, name)).toEqual({
+ mimeType: mime,
+ servingClass: "attachment",
+ });
+ });
+
+ it.each([
+ ["heic (major brand)", ftyp("heic")],
+ ["heix (major brand)", ftyp("heix")],
+ ["hevc (major brand)", ftyp("hevc")],
+ ["mif1 (major brand)", ftyp("mif1")],
+ ["msf1 (major brand)", ftyp("msf1")],
+ ["compatible-brand heic", ftyp("isom", ["heic"])],
+ ])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => {
+ expect(detectDocumentType(bytes, "photo.heic")).toEqual({
+ mimeType: "image/heic",
+ servingClass: "attachment",
+ });
+ });
+});
+
+describe("detectDocumentType — deny", () => {
+ it.each([
+ ["MZ executable", MZ, "payload.pdf"],
+ ["ELF executable", ELF, "scan.png"],
+ ["Mach-O executable", MACHO, "report.txt"],
+ ])("%s is denied regardless of a friendly extension", (_l, bytes, name) => {
+ expect(detectDocumentType(bytes, name)).toBeNull();
+ });
+
+ it.each([
+ ".exe",
+ ".dll",
+ ".bat",
+ ".cmd",
+ ".sh",
+ ".ps1",
+ ".js",
+ ".mjs",
+ ".jar",
+ ".apk",
+ ".html",
+ ".htm",
+ ".xhtml",
+ ".svg",
+ ".zip",
+ ".7z",
+ ".rar",
+ ".tar",
+ ".gz",
+ ".iso",
+ ".img",
+ ".dmg",
+ ])("extension %s is denied even with harmless text bytes", (ext) => {
+ expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull();
+ });
+
+ it("denies a ZIP container without an Office extension (incl. no name)", () => {
+ expect(detectDocumentType(ZIP, "archive.zip")).toBeNull();
+ expect(detectDocumentType(ZIP, "archive")).toBeNull();
+ expect(detectDocumentType(ZIP, null)).toBeNull();
+ // CFB without a legacy-Office extension is equally refused.
+ expect(detectDocumentType(CFB, "blob.bin")).toBeNull();
+ });
+
+ it("denies text-extension files smuggling NUL bytes", () => {
+ const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]);
+ expect(detectDocumentType(binaryish, "befund.txt")).toBeNull();
+ });
+
+ it("denies unidentifiable bytes and empty files", () => {
+ expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe(
+ null,
+ );
+ expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull();
+ // Text-like bytes under a non-text extension stay unidentifiable.
+ expect(detectDocumentType(TEXT, "notes.abc")).toBeNull();
+ });
+
+ it("adversarial: SVG content cannot sneak in under an image extension", () => {
+ const svg = Buffer.from('');
+ expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny
+ expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext
+ });
+
+ it("adversarial: HTML under a text extension stays attachment-only", () => {
+ // Accepted as text/plain — but Class B never renders inline, so the
+ // markup can never execute in-origin.
+ const html = Buffer.from("");
+ expect(detectDocumentType(html, "page.txt")).toEqual({
+ mimeType: "text/plain",
+ servingClass: "attachment",
+ });
+ expect(detectDocumentType(html, "page.html")).toBeNull();
+ });
+
+ it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => {
+ const polyglot = Buffer.concat([
+ Buffer.from("%PDF-1.4\n"),
+ Buffer.from(""),
+ ]);
+ expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({
+ mimeType: "application/pdf",
+ servingClass: "inline",
+ });
+ });
+});
+
+describe("servingClassFor", () => {
+ it("marks exactly the browser-native passive set inline", () => {
+ for (const mime of [
+ "application/pdf",
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ ]) {
+ expect(servingClassFor(mime)).toBe("inline");
+ }
+ });
+
+ it("marks every other stored type attachment (incl. unknowns)", () => {
+ for (const mime of [
+ "image/heic",
+ "image/tiff",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "text/plain",
+ "text/csv",
+ "application/rtf",
+ "application/xml",
+ "application/json",
+ "text/html", // must never happen at rest, but even then: attachment
+ "image/svg+xml",
+ "application/octet-stream",
+ ]) {
+ expect(servingClassFor(mime)).toBe("attachment");
+ }
+ });
+});
+
+describe("resolveDocumentLimits", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("falls back to the defaults with no settings row and no override", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
+ await expect(resolveDocumentLimits("u1")).resolves.toEqual({
+ maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES,
+ quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES,
+ });
+ });
+
+ it("clamps the per-file cap to the hard 100 MiB ceiling", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ documentMaxFileBytes: 999_999_999_999,
+ documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES),
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
+ const limits = await resolveDocumentLimits("u1");
+ expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES);
+ });
+
+ it("prefers the per-user quota override over the instance default", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ documentMaxFileBytes: 26_214_400,
+ documentQuotaBytes: BigInt(2_000_000_000),
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ documentQuotaBytes: BigInt(50_000_000),
+ } as never);
+ const limits = await resolveDocumentLimits("u1");
+ expect(limits.quotaBytes).toBe(50_000_000);
+ });
+
+ it("uses the instance quota when the user carries no override", async () => {
+ vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
+ documentMaxFileBytes: 26_214_400,
+ documentQuotaBytes: BigInt(2_000_000_000),
+ } as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue({
+ documentQuotaBytes: null,
+ } as never);
+ const limits = await resolveDocumentLimits("u1");
+ expect(limits.quotaBytes).toBe(2_000_000_000);
+ });
+});
diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts
index 8b9f90254..d10a12925 100644
--- a/src/lib/documents/store.ts
+++ b/src/lib/documents/store.ts
@@ -9,14 +9,16 @@
import { Buffer } from "node:buffer";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
-import { decrypt, encrypt } from "@/lib/crypto";
+import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import type {
ExtractedFact,
ExtractedFactStatus,
InboundDocument,
InboundDocumentStatus,
} from "@/generated/prisma/client";
+import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
+ DocumentConditionLinkDto,
ExtractedFactDto,
FactData,
FactProvenance,
@@ -26,6 +28,20 @@ import type {
InboundFactType,
} from "@/lib/validations/inbound-documents";
+/**
+ * The two storage codecs `InboundDocument.contentEncrypted` may carry,
+ * recorded per row in the explicit `contentCodec` column (never sniffed):
+ * - "base64v1" — the legacy string path (base64-of-binary → `encrypt()`
+ * string → UTF-8 bytes). Pre-vault rows only; kept read-compatible.
+ * - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no
+ * base64 detour. Every new upload writes this.
+ */
+export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const;
+export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number];
+
+/** The codec every NEW upload is written with. */
+export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2";
+
/** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */
export function encryptDocumentToBytes(bytes: Buffer): Uint8Array {
const ciphertext = encrypt(bytes.toString("base64"));
@@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
return Buffer.from(base64, "base64");
}
+/**
+ * Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the
+ * `Bytes` payload plus the codec label the row must persist alongside it.
+ */
+export function encryptDocumentContent(bytes: Buffer): {
+ content: Uint8Array;
+ codec: DocumentContentCodec;
+} {
+ const encrypted = encryptBytes(bytes);
+ const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength));
+ out.set(encrypted);
+ return { content: out, codec: ACTIVE_DOCUMENT_CODEC };
+}
+
+/**
+ * Decrypt a stored document's payload, dispatching on the row's persisted
+ * codec. Fail-closed: an unknown codec value throws — a silent fallback to
+ * either codec would either return garbage or mask data corruption.
+ */
+export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer {
+ if (codec === "base64v1") return decryptDocumentFromBytes(buf);
+ if (codec === "binary2") return decryptBytes(Buffer.from(buf));
+ throw new Error(`Unknown document content codec '${codec}'`);
+}
+
/**
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
* schema stores. The structured clinical values (diagnosis text, lab values,
@@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance {
return JSON.parse(decryptFromBytes(buf)) as FactProvenance;
}
-/** Map a persisted document row (+ counts) to the list/detail DTO. */
+/**
+ * The row fields the serialisers read. Deliberately WITHOUT
+ * `contentEncrypted`: the list path must never select the blob column
+ * (hardening — see the list route's `omit`), so the DTO mappers cannot
+ * require it either.
+ */
+export type SerialisableDocument = Omit;
+
+/** Map a persisted document row (+ counts + links) to the list/detail DTO. */
export function serialiseDocument(
- doc: InboundDocument,
+ doc: SerialisableDocument,
counts: { factCount: number; pendingCount: number },
+ conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDto {
return {
id: doc.id,
@@ -103,6 +153,8 @@ export function serialiseDocument(
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
+ conditionLinks,
+ servingClass: servingClassFor(doc.mimeType),
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
};
@@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
};
}
-/** Assemble the detail DTO (document + its staged facts). */
+/** Assemble the detail DTO (document + its staged facts + links). */
export function serialiseDocumentDetail(
- doc: InboundDocument,
+ doc: SerialisableDocument,
facts: ExtractedFact[],
+ conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
// of the document's tally) so the badge matches the list query.
const factCount = facts.filter((f) => f.status !== "REJECTED").length;
return {
- ...serialiseDocument(doc, { factCount, pendingCount }),
+ ...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks),
facts: facts.map(serialiseFact),
};
}
diff --git a/src/lib/documents/upload-policy.ts b/src/lib/documents/upload-policy.ts
new file mode 100644
index 000000000..c1aa63555
--- /dev/null
+++ b/src/lib/documents/upload-policy.ts
@@ -0,0 +1,370 @@
+/**
+ * Document-vault upload policy: the accept matrix, magic-byte sniffers, the
+ * serving-class function, and the admin-tunable limits resolver.
+ *
+ * Three classes, closed deny-list, serving posture as the security boundary:
+ *
+ * - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive
+ * formats the serve route renders `Content-Disposition: inline` (PDF
+ * additionally under a `Content-Security-Policy: sandbox` response
+ * header).
+ * - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/
+ * Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as
+ * `application/octet-stream` + `Content-Disposition: attachment` +
+ * `nosniff`. A misclassified Class B file therefore cannot execute in
+ * the app origin.
+ * - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose
+ * inline render executes script — zero medical-artefact value), generic
+ * archives / disk images, anything unidentifiable.
+ *
+ * The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes
+ * first, extension only where a container format is ambiguous (ZIP → OOXML,
+ * CFB → legacy Office) or where no magic exists (plain-text formats). We
+ * never parse, rewrite, or "sanitise" stored documents: the original is the
+ * artefact; safety comes from what the serve route will and won't serve
+ * inline.
+ *
+ * The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap
+ * and narrow allowlist unchanged; this module only reuses its bounded-read
+ * primitive at the route layer.
+ */
+import { detectAvatarMimeType } from "@/lib/avatar";
+import { prisma } from "@/lib/db";
+
+// ─── Limits (§3.3) ──────────────────────────────────────────────────────────
+
+/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */
+export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400;
+
+/**
+ * Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot
+ * AES-256-GCM + bounded in-memory reads are load-bearing; past this the
+ * architecture (chunking, streaming crypto) would have to change, so the
+ * setting cannot be pushed there.
+ */
+export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600;
+
+/** Default per-user quota: 1 GiB. */
+export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824;
+
+export interface DocumentLimits {
+ /** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */
+ maxFileBytes: number;
+ /** Per-user storage quota in bytes (user override ?? instance default). */
+ quotaBytes: number;
+}
+
+/**
+ * Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes`
+ * clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ??
+ * `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults.
+ */
+export async function resolveDocumentLimits(
+ userId: string,
+): Promise {
+ const [settings, user] = await Promise.all([
+ prisma.appSettings.findUnique({
+ where: { id: "singleton" },
+ select: { documentMaxFileBytes: true, documentQuotaBytes: true },
+ }),
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: { documentQuotaBytes: true },
+ }),
+ ]);
+ const rawMax =
+ settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES;
+ const maxFileBytes = Math.min(
+ Math.max(rawMax, 1),
+ DOCUMENT_HARD_MAX_FILE_BYTES,
+ );
+ const quotaBytes = Number(
+ user?.documentQuotaBytes ??
+ settings?.documentQuotaBytes ??
+ DOCUMENT_DEFAULT_QUOTA_BYTES,
+ );
+ return { maxFileBytes, quotaBytes };
+}
+
+// ─── Accept matrix (§3.1) ───────────────────────────────────────────────────
+
+export type DocumentServingClass = "inline" | "attachment";
+
+/** Class A — browser-native passive formats served inline. */
+const CLASS_A_MIMES: ReadonlySet = new Set([
+ "application/pdf",
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+]);
+
+/**
+ * The one source of truth the serve route uses: Class A renders inline with
+ * its true Content-Type; EVERYTHING else stored leaves the origin only as an
+ * opaque attachment download.
+ */
+export function servingClassFor(mimeType: string): DocumentServingClass {
+ return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment";
+}
+
+/**
+ * Extensions the upload UI's `accept` attribute mirrors (Class A + B minus
+ * HEIC — its exclusion makes the iOS picker auto-transcode camera photos to
+ * JPEG so the common path stays inline-previewable). Server-side policy
+ * remains the enforcement; this list is advisory for pickers.
+ */
+export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [
+ ".pdf",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".webp",
+ ".gif",
+ ".docx",
+ ".xlsx",
+ ".pptx",
+ ".doc",
+ ".xls",
+ ".ppt",
+ ".txt",
+ ".md",
+ ".csv",
+ ".rtf",
+ ".tif",
+ ".tiff",
+ ".xml",
+ ".json",
+];
+
+/**
+ * Extension deny-list: executables and scripts (regardless of content),
+ * HTML/XHTML/SVG (inline render executes script), generic archives and disk
+ * images. A ZIP container is allowed ONLY under an Office extension.
+ */
+const DENY_EXTENSIONS: ReadonlySet = new Set([
+ ".exe",
+ ".dll",
+ ".bat",
+ ".cmd",
+ ".sh",
+ ".ps1",
+ ".js",
+ ".mjs",
+ ".jar",
+ ".apk",
+ ".html",
+ ".htm",
+ ".xhtml",
+ ".svg",
+ ".zip",
+ ".7z",
+ ".rar",
+ ".tar",
+ ".gz",
+ ".iso",
+ ".img",
+ ".dmg",
+]);
+
+/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */
+const OOXML_BY_EXTENSION: ReadonlyMap = new Map([
+ [
+ ".docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ],
+ [
+ ".xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ],
+ [
+ ".pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ],
+]);
+
+/** Legacy Office extensions → stored MIME (CFB magic + extension required). */
+const CFB_BY_EXTENSION: ReadonlyMap = new Map([
+ [".doc", "application/msword"],
+ [".xls", "application/vnd.ms-excel"],
+ [".ppt", "application/vnd.ms-powerpoint"],
+]);
+
+/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */
+const TEXT_BY_EXTENSION: ReadonlyMap = new Map([
+ [".txt", "text/plain"],
+ [".md", "text/plain"],
+ [".csv", "text/csv"],
+ [".xml", "application/xml"],
+ [".json", "application/json"],
+]);
+
+/** Lower-cased extension (with dot) of a filename, or "" when absent. */
+function extensionOf(filename: string | null | undefined): string {
+ if (!filename) return "";
+ const idx = filename.lastIndexOf(".");
+ if (idx <= 0 || idx === filename.length - 1) return "";
+ return filename.slice(idx).toLowerCase();
+}
+
+function startsWith(buffer: Buffer, bytes: readonly number[]): boolean {
+ if (buffer.length < bytes.length) return false;
+ for (let i = 0; i < bytes.length; i++) {
+ if (buffer[i] !== bytes[i]) return false;
+ }
+ return true;
+}
+
+/** `%PDF-` */
+function isPdf(buffer: Buffer): boolean {
+ return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
+}
+
+/** `GIF87a` / `GIF89a` */
+function isGif(buffer: Buffer): boolean {
+ return (
+ startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
+ startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
+ );
+}
+
+/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */
+function isZip(buffer: Buffer): boolean {
+ return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]);
+}
+
+/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */
+function isCfb(buffer: Buffer): boolean {
+ return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
+}
+
+/** RTF `{\rtf` */
+function isRtf(buffer: Buffer): boolean {
+ return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]);
+}
+
+/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */
+function isTiff(buffer: Buffer): boolean {
+ return (
+ startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) ||
+ startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a])
+ );
+}
+
+/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */
+const HEIF_BRANDS: ReadonlySet = new Set([
+ "heic",
+ "heix",
+ "hevc",
+ "mif1",
+ "msf1",
+]);
+
+function isHeif(buffer: Buffer): boolean {
+ if (buffer.length < 12) return false;
+ if (buffer.toString("ascii", 4, 8) !== "ftyp") return false;
+ const major = buffer.toString("ascii", 8, 12).toLowerCase();
+ if (HEIF_BRANDS.has(major)) return true;
+ // Compatible-brand scan inside the ftyp box (bounded by the box length).
+ const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length);
+ for (let off = 16; off + 4 <= boxLen; off += 4) {
+ const brand = buffer.toString("ascii", off, off + 4).toLowerCase();
+ if (HEIF_BRANDS.has(brand)) return true;
+ }
+ return false;
+}
+
+/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */
+function isExecutable(buffer: Buffer): boolean {
+ if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ
+ if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF
+ const machO = [
+ [0xfe, 0xed, 0xfa, 0xce],
+ [0xfe, 0xed, 0xfa, 0xcf],
+ [0xce, 0xfa, 0xed, 0xfe],
+ [0xcf, 0xfa, 0xed, 0xfe],
+ [0xca, 0xfe, 0xba, 0xbe],
+ ] as const;
+ return machO.some((m) => startsWith(buffer, m));
+}
+
+/**
+ * Plain-text heuristic for the extension-gated text formats: no NUL byte in
+ * the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte
+ * sequence is valid Latin-1, so the effective gate is "no NUL" — a binary
+ * payload smuggled under `.txt` almost always carries NULs; if one does not,
+ * it is still stored Class B and can only ever leave as an opaque download).
+ */
+function looksLikeText(buffer: Buffer): boolean {
+ if (buffer.length === 0) return false;
+ const sample = buffer.subarray(0, 8192);
+ return !sample.includes(0x00);
+}
+
+export interface DetectedDocumentType {
+ /** The MIME type the row stores (sniffed, never the wire Content-Type). */
+ mimeType: string;
+ /** Derived serving class (same as `servingClassFor(mimeType)`). */
+ servingClass: DocumentServingClass;
+}
+
+/**
+ * Classify an upload from its bytes (+ the claimed filename, used only where
+ * a container/text format needs an extension gate). Returns null for every
+ * deny case: executables, script/markup extensions, generic archives, ZIPs
+ * without an Office extension, and anything unidentifiable.
+ */
+export function detectDocumentType(
+ buffer: Buffer,
+ filename: string | null | undefined,
+): DetectedDocumentType | null {
+ const ext = extensionOf(filename);
+
+ // Hard denies first: content-level executables, then the extension
+ // deny-list (an .exe named payload is refused even if its bytes look like
+ // text; HTML/SVG are refused regardless of content).
+ if (isExecutable(buffer)) return null;
+ if (DENY_EXTENSIONS.has(ext)) return null;
+
+ // ── Class A (magic bytes only — extension is irrelevant) ──
+ if (isPdf(buffer)) {
+ return { mimeType: "application/pdf", servingClass: "inline" };
+ }
+ if (isGif(buffer)) {
+ return { mimeType: "image/gif", servingClass: "inline" };
+ }
+ const image = detectAvatarMimeType(buffer);
+ if (image) {
+ return { mimeType: image, servingClass: "inline" };
+ }
+
+ // ── Class B (magic where it exists; containers need the extension) ──
+ if (isZip(buffer)) {
+ // A ZIP container is acceptable ONLY as an OOXML Office file; a generic
+ // archive (any other extension, incl. none) is denied.
+ const ooxml = OOXML_BY_EXTENSION.get(ext);
+ return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null;
+ }
+ if (isCfb(buffer)) {
+ const legacy = CFB_BY_EXTENSION.get(ext);
+ return legacy ? { mimeType: legacy, servingClass: "attachment" } : null;
+ }
+ if (isRtf(buffer)) {
+ return { mimeType: "application/rtf", servingClass: "attachment" };
+ }
+ if (isTiff(buffer)) {
+ return { mimeType: "image/tiff", servingClass: "attachment" };
+ }
+ if (isHeif(buffer)) {
+ return { mimeType: "image/heic", servingClass: "attachment" };
+ }
+
+ // ── Text formats (extension + heuristic; no magic bytes exist) ──
+ const text = TEXT_BY_EXTENSION.get(ext);
+ if (text && looksLikeText(buffer)) {
+ return { mimeType: text, servingClass: "attachment" };
+ }
+
+ // Unidentifiable → deny.
+ return null;
+}