Files
HealthLog/src/app/api/documents/inbound/route.ts
T
Marc-André Bombeck 85a343a68c feat(documents): assist, summary, content-index endpoints + search union
P2-1-T2/T3/T4/T5/T6.

- assist.ts + [id]/suggest: one provider call proposes a { title, kind,
  documentDate } filing draft over the stored original or browser-OCR text.
  Runs the full extract gauntlet (module → provider → consent → rate-limit →
  budget) but writes nothing — never touches the fact rail or status.
- describe.ts + [id]/summary?mode=summary|text: session-only summary or raw
  transcribed text. Descriptive only, no diagnosis; persists only the budget
  ledger + audit.
- content-index.ts + [id]/index: normalise → tokenise (de-accent, stopword
  drop, whole-word) → HMAC-SHA256 under an HKDF-derived subkey → store the
  token array plus AES-256-GCM ciphertext of the text. Vision path transcribes
  via the provider (consent + budget); text path indexes posted local-OCR text
  (no egress). Gated on the existing AI consent — no new per-user toggle.
- crypto.deriveSubkey: HKDF-SHA256 purpose-bound subkey off the active key.
- list search now unions title/filename ILIKE with a GIN-accelerated
  whole-word content-token match; DTO gains hasContentIndex.
- usage gains assistAvailable + contentIndex { enabled, indexedCount,
  totalCount }; detail gains hasContentIndex.
2026-07-07 18:17:13 +02:00

534 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Document vault: store-first upload + browsable list.
*
* POST is STORE-ONLY and provider-free. A self-hoster uploads any accepted
* document; it is stored ENCRYPTED at rest (binary codec) with
* `status: STORED` and NO extraction run. There is no provider resolution, no
* AI consent guard, no budget, and NO egress on this path — a file can always
* be filed, even on an account with no document-scan provider configured.
*
* The upload path enforces the vault policy layer
* (`src/lib/documents/upload-policy.ts`): magic-byte classification (the wire
* Content-Type is never trusted), the admin-tunable per-file cap (bounded
* read aborts at the cap), the per-user quota (checked in the same
* transaction as the insert, tombstone-inclusive), sha256 duplicate detection
* (a same-user re-upload returns the existing live row, never a second copy),
* an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking.
*
* GET lists the caller's documents with title/filename search, kind /
* episode / year / date-range filters, sort, and keyset pagination — and
* NEVER selects the encrypted blob column (`omit: { contentEncrypted: true }`).
*
* The document is UNTRUSTED (prompt-injection): the server never acts on an
* instruction inside it. Storing it does nothing with its contents.
*/
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { NextResponse } 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 { hashQueryTokens } from "@/lib/documents/content-index";
import {
loadConditionLinks,
narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
encryptDocumentContent,
serialiseDocument,
type SerialisableDocument,
} from "@/lib/documents/store";
import {
detectDocumentType,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
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 {
documentCreateSchema,
documentListQuerySchema,
} from "@/lib/validations/inbound-documents";
import type { Prisma } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
/**
* A store-only upload touches no provider — the only abuse vector is disk, so
* a generous per-user ceiling is enough (extraction has its own tight gate).
*/
const UPLOAD_LIMIT_PER_HOUR = 60;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
/**
* Multipart envelope allowance on top of the per-file cap: boundaries plus
* the small metadata fields (title / kind / documentDate / episodeIds). The
* exact per-file cap is re-enforced on the extracted file bytes below.
*/
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
function isoDateToUtc(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
function fileTooLarge(maxFileBytes: number): NextResponse {
return apiError("File is too large.", 413, {
errorCode: "documents.inbound.fileTooLarge",
reason: "fileTooLarge",
maxFileBytes,
});
}
/** Internal signal: the quota gate inside the insert transaction tripped. */
class QuotaExceededError extends Error {
constructor(public readonly usedBytes: number) {
super("Document quota exceeded");
this.name = "QuotaExceededError";
}
}
/**
* §3.2 — a duplicate upload is NOT an error: return the existing live row
* with `meta.duplicate: true` at the envelope level (the UI toasts "already
* stored" and highlights the row).
*/
async function duplicateResponse(
userId: string,
existing: SerialisableDocument,
): Promise<NextResponse> {
const [links, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
prisma.extractedFact.groupBy({
by: ["status"],
where: { userId, documentId: existing.id },
_count: { _all: true },
}),
]);
let factCount = 0;
let pendingCount = 0;
for (const g of groups) {
if (g.status !== "REJECTED") factCount += g._count._all;
if (g.status === "PENDING") pendingCount += g._count._all;
}
annotate({
action: { name: "documents.vault.upload" },
meta: { documentId: existing.id, duplicate: true },
});
return NextResponse.json(
{
data: serialiseDocument(
existing,
{ factCount, pendingCount },
links.get(existing.id) ?? [],
),
error: null,
meta: { duplicate: true },
},
{ status: 200 },
);
}
/** POST — store an uploaded document encrypted at rest. No extraction. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
// Opt-in module gate — even a valid Bearer token is refused when the surface
// is off (it ships dark; the user turns it on deliberately).
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`documents-upload:${user.id}`,
UPLOAD_LIMIT_PER_HOUR,
UPLOAD_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many uploads. 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 limits = await resolveDocumentLimits(user.id);
const bodyCap = limits.maxFileBytes + MULTIPART_OVERHEAD_BYTES;
// Instant rejection on a declared oversize (a CD/ISO-sized upload never
// allocates); the bounded read below covers chunked/undeclared bodies.
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > bodyCap) {
return fileTooLarge(limits.maxFileBytes);
}
let formData: FormData;
try {
const bytes = await readBoundedBody(request.body, bodyCap);
formData = await new Response(new Blob([bytes]), {
headers: { "content-type": request.headers.get("content-type") ?? "" },
}).formData();
} catch (err) {
if (err instanceof BodyTooLargeError) {
return fileTooLarge(limits.maxFileBytes);
}
return apiError("Invalid multipart body", 400);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("Field 'file' must be a file", 422);
}
// Optional metadata (title / kind / documentDate / episodeIds). The file is
// read separately; these are the form fields beside it. `episodeIds` may be
// repeated.
const rawEpisodeIds = formData
.getAll("episodeIds")
.filter((v): v is string => typeof v === "string");
const parsed = documentCreateSchema.safeParse({
title: formData.get("title") ?? undefined,
kind: formData.get("kind") ?? undefined,
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
}
let buffer: Buffer;
try {
buffer = Buffer.from(await file.arrayBuffer());
} catch {
return apiError("Failed to read uploaded file", 400);
}
if (buffer.byteLength > limits.maxFileBytes) {
return fileTooLarge(limits.maxFileBytes);
}
if (buffer.byteLength === 0) {
return apiError("Uploaded file is empty", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
}
// §3.1 — magic-byte classification; the wire Content-Type is never trusted.
const detected = detectDocumentType(
buffer,
typeof file.name === "string" ? file.name : null,
);
if (!detected) {
return apiError("This file type is not supported.", 415, {
errorCode: "documents.inbound.fileType",
reason: "unsupportedType",
});
}
// Pre-linking: every episode id must be a LIVE episode of the caller.
const episodeIds = await narrowOwnedEpisodeIds(
user.id,
parsed.data.episodeIds ?? [],
);
if (episodeIds === null) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
// sha256 of the PLAINTEXT for same-user duplicate detection.
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
// Fast-path dedupe check; the partial unique index closes the race below.
const existing = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (existing) {
return duplicateResponse(user.id, existing);
}
// Quota gate + insert + pre-links in ONE transaction. Usage counts every
// non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes
// are never invisible weight (undo-delete never changes usage).
let document: SerialisableDocument;
try {
document = await prisma.$transaction(async (tx) => {
// Serialise the quota gate per user: without this, N concurrent
// uploads all read the same SUM before any of them commits and the
// quota can be overshot by up to N × cap in one burst. The advisory
// lock is transaction-scoped (released on commit/rollback) and keyed
// on the user id, so uploads by different users never queue on each
// other.
// (`pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column — selecting FROM it yields a plain int row.)
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0))
`;
const rows = await tx.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`;
const usedBytes = Number(rows[0]?.used ?? 0);
if (usedBytes + buffer.byteLength > limits.quotaBytes) {
throw new QuotaExceededError(usedBytes);
}
const { content, codec } = encryptDocumentContent(buffer);
// No mass assignment — every column is set field-by-field; `userId`
// comes from the session, never the body. `documentDate` defaults to
// the upload day so display == sort == filter (user-editable later).
const created = await tx.inboundDocument.create({
data: {
userId: user.id,
kind: parsed.data.kind ?? "OTHER",
// Stored plaintext on purpose (mirrors `filename`) so the list can
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that
// is the accepted tradeoff for server-side search/sort — the
// document body stays encrypted.
title: parsed.data.title ?? null,
filename:
typeof file.name === "string" ? file.name.slice(0, 255) : null,
mimeType: detected.mimeType,
byteSize: buffer.byteLength,
contentEncrypted: content,
contentCodec: codec,
contentSha256,
status: "STORED",
documentDate: parsed.data.documentDate
? isoDateToUtc(parsed.data.documentDate)
: new Date(),
},
omit: { contentEncrypted: true },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId: created.id,
episodeId,
userId: user.id,
})),
});
}
return created;
});
} catch (err) {
if (err instanceof QuotaExceededError) {
return apiError("Storage quota exceeded.", 413, {
errorCode: "documents.inbound.quotaExceeded",
reason: "quotaExceeded",
quotaBytes: limits.quotaBytes,
usedBytes: err.usedBytes,
});
}
if (isP2002(err)) {
// A racing upload of the same bytes won the partial unique index.
// Surface the winner as the duplicate — same outcome as the fast path.
const winner = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (winner) return duplicateResponse(user.id, winner);
}
throw err;
}
await auditLog("documents.inbound.store", {
userId: user.id,
ipAddress: getClientIp(request),
details: { documentId: document.id, mime: detected.mimeType },
});
annotate({
action: { name: "documents.vault.upload" },
meta: {
documentId: document.id,
byteSize: document.byteSize,
servingClass: detected.servingClass,
linked: episodeIds.length,
},
});
const links = await loadConditionLinks(user.id, [document.id]);
return apiSuccess(
serialiseDocument(
document,
{ factCount: 0, pendingCount: 0 },
links.get(document.id) ?? [],
),
201,
);
}
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
/** GET — list the caller's documents (search / filter / sort / paginate). */
export const GET = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const url = new URL(request.url);
// `kind` is a multi-value facet: repeated params and/or comma-separated.
const kinds = url.searchParams
.getAll("kind")
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v) => v.length > 0);
const single = Object.fromEntries(url.searchParams);
const parsed = documentListQuerySchema.safeParse({
...single,
kind: kinds.length > 0 ? kinds : undefined,
});
if (!parsed.success) {
return apiError("Invalid list query", 422, {
errorCode: "documents.inbound.invalidQuery",
});
}
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
parsed.data;
const where: Prisma.InboundDocumentWhereInput = {
userId: user.id,
deletedAt: null,
};
if (kind && kind.length > 0) where.kind = { in: kind };
if (episodeId) where.conditionLinks = { some: { episodeId } };
if (year !== undefined) {
where.documentDate = {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
};
} else if (from || to) {
where.documentDate = {
...(from ? { gte: isoDateToUtc(from) } : {}),
// inclusive end-of-day for the `to` bound
...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}),
};
}
if (q) {
// Substring match on the short plaintext fields …
const or: Prisma.InboundDocumentWhereInput[] = [
{ title: { contains: q, mode: "insensitive" } },
{ filename: { contains: q, mode: "insensitive" } },
];
// … unioned with a WHOLE-WORD content match over the blind token index.
// The query is tokenised + HMAC'd the same way the index was built, then
// matched with a GIN-accelerated array-overlap (`hasSome` → `&&`). The
// list still never selects the encrypted text — only the opaque hashes are
// touched, in the related table. Degrades silently to title/filename when
// the caller has no indexed documents (no rows overlap).
const hashes = hashQueryTokens(q);
if (hashes.length > 0) {
or.push({ contentIndex: { is: { searchTokens: { hasSome: hashes } } } });
}
where.OR = or;
}
// Keyset pagination on the sort column + a stable `id` tiebreak; nullable
// sort columns sort nulls last so undated documents trail.
const primaryOrder:
Prisma.InboundDocumentOrderByWithRelationInput | undefined =
sort === "documentDate"
? { documentDate: { sort: order, nulls: "last" } }
: sort === "title"
? { title: { sort: order, nulls: "last" } }
: { createdAt: order };
const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [
primaryOrder,
{ id: order },
];
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
// Fact tallies for the whole page in ONE grouped count — no per-document
// fan-out and no materialising fact-id rows. `factCount` counts every
// non-REJECTED fact (a rejected fact is discarded, not part of the
// document's tally), `pendingCount` the PENDING subset awaiting review.
const factCounts = new Map<
string,
{ factCount: number; pendingCount: number }
>();
if (page.length > 0) {
const groups = await prisma.extractedFact.groupBy({
by: ["documentId", "status"],
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
_count: { _all: true },
});
for (const g of groups) {
const entry = factCounts.get(g.documentId) ?? {
factCount: 0,
pendingCount: 0,
};
const n = g._count._all;
if (g.status !== "REJECTED") entry.factCount += n;
if (g.status === "PENDING") entry.pendingCount += n;
factCounts.set(g.documentId, entry);
}
}
// Condition links for the page in ONE grouped query (no N+1).
const linkMap = await loadConditionLinks(
user.id,
page.map((d) => d.id),
);
// Which of the page's documents have a content index (drives `hasContentIndex`
// + the re-index affordance). One indexed grouped query; never the ciphertext.
const indexedIds = new Set<string>();
if (page.length > 0) {
const indexed = await prisma.documentContentIndex.findMany({
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
select: { documentId: true },
});
for (const row of indexed) indexedIds.add(row.documentId);
}
annotate({
action: { name: "documents.inbound.list" },
meta: {
count: page.length,
sort,
order,
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
},
});
return apiSuccess({
documents: page.map((doc) =>
serialiseDocument(
doc,
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
linkMap.get(doc.id) ?? [],
indexedIds.has(doc.id),
),
),
nextCursor,
});
});