feat(documents): add a document upload token for imports from other systems

A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.

Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.

The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.

Refs #1038, #938
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 21:48:04 +02:00
1 parent af65f01fec
commit 55a32ff7d1
45 files changed
+2261 -96

No files matched your search

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Document vault: store-only upload + browsable list.
@@ -50,6 +50,9 @@ vi.mock("@/lib/db", () => {
findMany: vi.fn(),
findFirst: vi.fn(),
},
documentImportKey: {
findUnique: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
@@ -114,6 +117,12 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// The Bearer path: the real `requireAuth` runs, and only the token lookup is
// stubbed, so the scope decision under test is the production one.
vi.mock("@/lib/auth/bearer", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/bearer")>();
return { ...actual, resolveBearerToken: vi.fn() };
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -135,6 +144,11 @@ import { createHash } from "node:crypto";
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
import { resolveBearerToken, BearerAuthError } from "@/lib/auth/bearer";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
@@ -219,6 +233,9 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue(
null as never,
);
// No settings row / no user override → policy defaults (25 MiB / 1 GiB).
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
@@ -581,3 +598,216 @@ describe("POST /api/documents/inbound — AI work on upload follows documentAi",
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
});
});
/**
* v1.39.2 (#1038) — the narrow `documents:write` token, and the import fields
* any caller may send. The Bearer is resolved by the real `requireAuth`; only
* the token lookup is stubbed.
*/
describe("POST /api/documents/inbound — document import", () => {
const TOKEN = "hlk_" + "d".repeat(64);
function armToken(permissions: string[]) {
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(headers).mockResolvedValue({
get: (name: string) =>
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
} as never);
vi.mocked(resolveBearerToken).mockImplementation(async (_raw, req) => {
const admitted =
permissions.includes("*") ||
(req.kind === "scope" && permissions.includes(req.scope));
if (!admitted) {
throw new BearerAuthError(
403,
"insufficient_permissions",
"user-1",
"tok-1",
);
}
return {
user: { id: "user-1", username: "tester", role: "USER" },
tokenId: "tok-1",
expiresAt: new Date(Date.now() + 3_600_000),
permissions,
} as never;
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 3_600_000,
} as never);
});
afterEach(() => {
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
});
it("admits the scoped token and answers with a receipt, not the row", async () => {
armToken([DOCUMENTS_WRITE_SCOPE]);
txCreate.mockResolvedValue(
docRow({ title: "Befund", filename: "befund.png" }) as never,
);
const res = await post(mkUpload({ title: "Befund" }));
expect(res.status).toBe(201);
const body = await res.json();
// Exactly the receipt: no title, filename, kind, links or counts.
expect(body.data).toEqual({ id: "doc-1", duplicate: false });
});
it("gives the scoped token its own bucket, keyed on the token", async () => {
armToken([DOCUMENTS_WRITE_SCOPE]);
await post(mkUpload());
const key = vi.mocked(checkRateLimit).mock.calls[0]![0];
expect(key).toBe("documents-upload:token:tok-1");
// Default 120/hour for a token; the cookie bucket stays at 60.
expect(vi.mocked(checkRateLimit).mock.calls[0]![1]).toBe(120);
});
it("keeps the cookie bucket for a session", async () => {
await post(mkUpload());
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
"documents-upload:user-1",
);
expect(vi.mocked(checkRateLimit).mock.calls[0]![1]).toBe(60);
});
it("429s the scoped token past its bucket with Retry-After", async () => {
armToken([DOCUMENTS_WRITE_SCOPE]);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
limit: 120,
resetAt: Date.now() + 90_000,
} as never);
const res = await post(mkUpload());
expect(res.status).toBe(429);
expect((await res.json()).meta.errorCode).toBe(
"documents.inbound.rateLimited",
);
expect(txCreate).not.toHaveBeenCalled();
});
it("refuses a token carrying some other narrow scope", async () => {
armToken(["measurements:write"]);
const res = await post(mkUpload());
expect(res.status).toBe(403);
expect(txCreate).not.toHaveBeenCalled();
});
it("answers a scoped content duplicate with the receipt", async () => {
armToken([DOCUMENTS_WRITE_SCOPE]);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
docRow({ id: "doc-existing", title: "Private title" }) as never,
);
const res = await post(mkUpload());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ id: "doc-existing", duplicate: true });
expect(body.meta.duplicate).toBe(true);
});
it("stores the source key on a fresh import", async () => {
armToken([DOCUMENTS_WRITE_SCOPE]);
await post(mkUpload({ sourceSystem: "PAPERLESS", sourceId: "412" }));
const arg = txCreate.mock.calls[0]![0]!;
expect(arg.data.sourceSystem).toBe("PAPERLESS");
expect(arg.data.sourceId).toBe("412");
});
it("answers a live source-key match as a duplicate without reading bytes", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValueOnce(
docRow({
id: "doc-src",
sourceSystem: "PAPRA",
sourceId: "doc_1",
}) as never,
);
const res = await post(
mkUpload({ sourceSystem: "PAPRA", sourceId: "doc_1" }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe("doc-src");
// The lookup spans tombstones: no deletedAt predicate.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
.where!;
expect(where).toEqual({
userId: "user-1",
sourceSystem: "PAPRA",
sourceId: "doc_1",
});
expect(txCreate).not.toHaveBeenCalled();
});
it("refuses to bring back a deleted import: 200 deleted, no new row", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValueOnce(
docRow({
id: "doc-gone",
sourceSystem: "PAPERLESS",
sourceId: "7",
deletedAt: new Date("2026-09-01T00:00:00.000Z"),
}) as never,
);
const res = await post(
mkUpload({ sourceSystem: "PAPERLESS", sourceId: "7" }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({
id: "doc-gone",
duplicate: true,
deleted: true,
});
expect(body.meta).toEqual({ duplicate: true, deleted: true });
expect(txCreate).not.toHaveBeenCalled();
expect(enqueueDocumentIndex).not.toHaveBeenCalled();
});
it("remembers a purged import through the ledger", async () => {
vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue({
id: "key-1",
} as never);
const res = await post(
mkUpload({ sourceSystem: "PAPERLESS", sourceId: "7" }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ id: null, duplicate: true, deleted: true });
expect(txCreate).not.toHaveBeenCalled();
});
it("422s a sourceId without a sourceSystem", async () => {
const res = await post(mkUpload({ sourceId: "7" }));
expect(res.status).toBe(422);
expect(txCreate).not.toHaveBeenCalled();
});
it("422s a sourceSystem outside the closed set", async () => {
const res = await post(
mkUpload({ sourceSystem: "DROPBOX", sourceId: "7" }),
);
expect(res.status).toBe(422);
});
it("aiRead=defer indexes locally and skips the summary", async () => {
const res = await post(mkUpload({ aiRead: "defer" }));
expect(res.status).toBe(201);
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1", {
localOnly: true,
});
expect(enqueueDocumentThumbnail).toHaveBeenCalledWith("user-1", "doc-1");
expect(enqueueDocumentSummary).not.toHaveBeenCalled();
});
it("without aiRead the upload keeps today's AI behaviour", async () => {
await post(mkUpload());
expect(enqueueDocumentIndex).toHaveBeenCalledWith("user-1", "doc-1");
expect(enqueueDocumentSummary).toHaveBeenCalledWith("user-1", "doc-1");
});
});
+211 -22
View File
@@ -15,6 +15,15 @@
* (a same-user re-upload returns the existing live row, never a second copy),
* an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking.
*
* v1.39.2 (#1038) — POST also admits a narrow `documents:write` token, the
* door another document system (a Paperless workflow, the import script)
* pushes through. Such a caller gets its own rate bucket, and a receipt
* (`{ id, duplicate }`) instead of the stored row: a credential that can only
* add files learns that a file exists, never how its owner filed it. Optional
* `sourceSystem` / `sourceId` fields key an import so a re-send is a
* duplicate, including after the person deleted the document; `aiRead=defer`
* holds back automatic AI reading for the upload.
*
* 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 }`).
@@ -29,6 +38,7 @@ import { NextResponse } from "next/server";
import {
apiHandler,
isScopedCredential,
requireAuth,
requireRecordAuth,
type AuthContext,
@@ -58,10 +68,12 @@ import {
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import {
acquireDocumentUploadSlot,
detectDocumentType,
resolveDocumentLimits,
resolveDocumentUploadLimitPerHour,
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
@@ -74,6 +86,7 @@ import {
documentCreateSchema,
documentListQuerySchema,
toContentIndexSource,
type DocumentSourceSystemValue,
} from "@/lib/validations/inbound-documents";
import type { Prisma } from "@/generated/prisma/client";
@@ -116,15 +129,93 @@ class QuotaExceededError extends Error {
}
}
/** What a single upload asked for, carried into the annotations. */
interface UploadContext {
userId: string;
/** A narrow `documents:write` token, not a session or a `["*"]` token. */
scoped: boolean;
sourceSystem: DocumentSourceSystemValue | null;
aiDeferred: boolean;
}
/**
* The receipt a scoped caller gets in place of the stored row: the id and
* whether the upload was a duplicate. A write-only credential holder who
* knows a file's bytes learns that it is stored, not its title, filename,
* links or fact counts.
*/
function receiptResponse(
id: string | null,
status: 200 | 201,
flags: { duplicate: boolean; deleted?: boolean },
): NextResponse {
const data = {
id,
duplicate: flags.duplicate,
...(flags.deleted ? { deleted: true } : {}),
};
return NextResponse.json(
{
data,
error: null,
...(flags.duplicate
? {
meta: {
duplicate: true,
...(flags.deleted ? { deleted: true } : {}),
},
}
: {}),
},
{ status },
);
}
/**
* v1.39.2 (#1038) — a re-send of an imported document the person deleted.
* 200 and no new row, for every caller: the deletion was a decision, and an
* importer re-running over the same source must not undo it. `id` is the
* tombstoned row while it exists and null once the purge took it; there is no
* row left to serialise either way, so the answer is the receipt.
*/
function deletedResponse(ctx: UploadContext, id: string | null): NextResponse {
annotate({
action: { name: "documents.vault.upload" },
meta: {
documentId: id,
duplicate: true,
deleted: true,
scoped: ctx.scoped,
sourceSystem: ctx.sourceSystem,
aiDeferred: ctx.aiDeferred,
},
});
return receiptResponse(id, 200, { duplicate: true, deleted: true });
}
/**
* §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).
* stored" and highlights the row). A scoped caller gets the receipt instead.
*/
async function duplicateResponse(
userId: string,
ctx: UploadContext,
existing: SerialisableDocument,
): Promise<NextResponse> {
annotate({
action: { name: "documents.vault.upload" },
meta: {
documentId: existing.id,
duplicate: true,
scoped: ctx.scoped,
sourceSystem: ctx.sourceSystem,
aiDeferred: ctx.aiDeferred,
},
});
if (ctx.scoped) {
return receiptResponse(existing.id, 200, { duplicate: true });
}
const userId = ctx.userId;
const [links, visitLinks, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
loadDocumentEncounterLinks(userId, [existing.id]),
@@ -140,10 +231,6 @@ async function duplicateResponse(
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(
@@ -165,18 +252,31 @@ async function duplicateResponse(
/** Process one admitted upload. The caller owns and releases its memory slot. */
async function processUpload(
request: Request,
user: AuthContext["user"],
auth: AuthContext,
): Promise<Response> {
const { user } = auth;
const scoped = isScopedCredential(auth);
// 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,
);
// A scoped token draws on its own bucket, keyed on the token (its id rides
// `session.id` on the Bearer path), so an import running overnight cannot
// use up the allowance the person's own uploads from the web or the phone
// draw on. The cookie / wildcard bucket is unchanged.
const rl = scoped
? await checkRateLimit(
`documents-upload:token:${auth.session.id}`,
resolveDocumentUploadLimitPerHour(),
UPLOAD_WINDOW_MS,
)
: 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",
@@ -241,6 +341,9 @@ async function processUpload(
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
sourceSystem: formData.get("sourceSystem") ?? undefined,
sourceId: formData.get("sourceId") ?? undefined,
aiRead: formData.get("aiRead") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -253,6 +356,39 @@ async function processUpload(
);
}
const sourceSystem = parsed.data.sourceSystem ?? null;
const sourceId = sourceSystem ? (parsed.data.sourceId ?? null) : null;
const ctx: UploadContext = {
userId: user.id,
scoped,
sourceSystem,
aiDeferred: parsed.data.aiRead === "defer",
};
// A source key answers before the bytes are looked at: an import re-sending
// what it sent before is a duplicate, and one re-sending a document the
// person deleted is refused a second copy — live and tombstoned rows both
// count (the unique index has no `deleted_at` predicate), and past the purge
// the ledger remembers.
if (sourceSystem && sourceId) {
const keyed = await findBySourceKey(user.id, sourceSystem, sourceId);
if (keyed)
return keyed.deletedAt
? deletedResponse(ctx, keyed.id)
: duplicateResponse(ctx, keyed);
const purged = await prisma.documentImportKey.findUnique({
where: {
userId_sourceSystem_sourceId: {
userId: user.id,
sourceSystem,
sourceId,
},
},
select: { id: true },
});
if (purged) return deletedResponse(ctx, null);
}
let buffer: Buffer;
try {
buffer = Buffer.from(await file.arrayBuffer());
@@ -313,7 +449,7 @@ async function processUpload(
omit: { contentEncrypted: true },
});
if (existing) {
return duplicateResponse(user.id, existing);
return duplicateResponse(ctx, existing);
}
// Quota gate + insert + pre-links in ONE transaction. Usage counts every
@@ -369,6 +505,8 @@ async function processUpload(
documentDate: parsed.data.documentDate
? isoDateToUtc(parsed.data.documentDate)
: new Date(),
sourceSystem,
sourceId,
},
omit: { contentEncrypted: true },
});
@@ -403,13 +541,22 @@ async function processUpload(
});
}
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.
// A racing upload won one of the two partial unique indexes — the same
// source key, or the same bytes. Surface the winner exactly as the fast
// paths above would have.
if (sourceSystem && sourceId) {
const keyed = await findBySourceKey(user.id, sourceSystem, sourceId);
if (keyed) {
return keyed.deletedAt
? deletedResponse(ctx, keyed.id)
: duplicateResponse(ctx, keyed);
}
}
const winner = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (winner) return duplicateResponse(user.id, winner);
if (winner) return duplicateResponse(ctx, winner);
}
throw err;
}
@@ -417,7 +564,12 @@ async function processUpload(
await auditLog("documents.inbound.store", {
userId: user.id,
ipAddress: getClientIp(request),
details: { documentId: document.id, mime: detected.mimeType },
details: {
documentId: document.id,
mime: detected.mimeType,
...(scoped ? { scoped: true } : {}),
...(sourceSystem ? { sourceSystem } : {}),
},
});
annotate({
@@ -428,6 +580,9 @@ async function processUpload(
servingClass: detected.servingClass,
linked: episodeIds.length,
linkedVisits: encounterIds.length,
scoped,
sourceSystem,
aiDeferred: ctx.aiDeferred,
},
});
@@ -437,7 +592,15 @@ async function processUpload(
// enqueue is not awaited and swallows its own errors (a missing boss or a
// transient send failure is a silent no-op). Only fresh inserts enqueue — a
// duplicate upload returns early above and never reaches here.
void enqueueDocumentIndex(user.id, document.id);
//
// `aiRead=defer` narrows it to the local text layer: an import of a whole
// archive must not turn into one provider call per file. The person reads
// them later, deliberately, from the document itself.
if (ctx.aiDeferred) {
void enqueueDocumentIndex(user.id, document.id, { localOnly: true });
} else {
void enqueueDocumentIndex(user.id, document.id);
}
// Render a preview thumbnail in the background too (pure local compute — no
// egress). Same fire-and-forget contract: the upload never blocks on or fails
@@ -453,7 +616,12 @@ async function processUpload(
// provider-free text-layer path. The persisted summary shows on the detail
// view. Same fire-and-forget contract: the upload never blocks on or fails
// because of it. Only fresh inserts reach here (a duplicate returns early).
const documentAi = await getAiCapability("documentAi");
//
// A deferred upload skips it outright and keeps `summaryState: NONE`, so
// the detail sheet offers "Generate summary" rather than a pending state.
const documentAi = ctx.aiDeferred
? { available: false as const, reason: "deferred" }
: await getAiCapability("documentAi");
if (documentAi.available) {
void enqueueDocumentSummary(user.id, document.id);
} else {
@@ -463,6 +631,8 @@ async function processUpload(
});
}
if (scoped) return receiptResponse(document.id, 201, { duplicate: false });
const [links, visitLinks] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
loadDocumentEncounterLinks(user.id, [document.id]),
@@ -481,9 +651,28 @@ async function processUpload(
);
}
/**
* Find a document by its import source key, live or tombstoned. The blob
* column is never selected.
*/
function findBySourceKey(
userId: string,
sourceSystem: DocumentSourceSystemValue,
sourceId: string,
): Promise<SerialisableDocument | null> {
return prisma.inboundDocument.findFirst({
where: { userId, sourceSystem, sourceId },
omit: { contentEncrypted: true },
});
}
/** Authenticate and reserve memory capacity before any request-body read. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
// The one route that names `documents:write`. Every other vault leg —
// list, detail, original, thumbnail, bulk, AI — declares no scope and so
// refuses the token (`bearer-scope-enforcement-guard.test.ts`).
const auth = await requireAuth(DOCUMENTS_WRITE_SCOPE);
const { user } = auth;
const releaseUploadSlot = acquireDocumentUploadSlot(user.id);
if (!releaseUploadSlot) {
const response = apiError(
@@ -499,7 +688,7 @@ async function postUpload(request: Request): Promise<Response> {
}
try {
return await processUpload(request, user);
return await processUpload(request, auth);
} finally {
releaseUploadSlot();
}
@@ -517,7 +706,7 @@ export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
* letter is health data belonging to the record, and reading it is the whole
* point of handing somebody the record.
*
* The POST above is NOT delegable and keeps `requireAuth()`. Uploading is not
* The POST above is NOT delegable and declares its own scope. Uploading is not
* a verb the grant admits, and the module-level split matters here: the
* resolver escalates any non-safe method to `"write"`, so the two arms have to
* declare separately rather than share one line.
@@ -0,0 +1,220 @@
/**
* The document-upload mint (#1038).
*
* The assertion that matters most is not the 201 — it is the exact
* `permissions` array handed to `issueApiToken`. That helper defaults to
* `["*"]` when the property is absent, so an edit that drops one line turns a
* user-facing endpoint into a cookie-equivalent credential factory, and a test
* that only checked the status code would go on passing. The structural guard
* catches a literal `permissions: ["*"` and would not catch the omission, so
* this is where that hole is closed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/db", () => ({
prisma: { apiToken: { count: vi.fn() } },
}));
import { POST } from "../route";
import { requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
const USER = { id: "user-1", role: "USER" as const };
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
function req(body: unknown): Request {
return new Request("https://health.example/api/tokens/documents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// Below the ceiling by default; the cases that care set their own count.
vi.mocked(prisma.apiToken.count).mockResolvedValue(0 as never);
vi.mocked(issueApiToken).mockResolvedValue({
token: "hlk_" + "a".repeat(64),
expiresAt: EXPIRES,
tokenId: "token-1",
name: "Paperless workflow",
} as never);
});
describe("what it mints", () => {
it("names exactly the upload scope, and no wildcard", async () => {
const res = await POST(req({ name: "Paperless workflow" }) as never);
expect(res.status).toBe(201);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
// Exact equality, not `toContain`: an extra scope is a widening, and this
// is the assertion standing between the endpoint and one.
expect(opts.permissions).toEqual([DOCUMENTS_WRITE_SCOPE]);
expect(opts.userId).toBe(USER.id);
});
it("returns the raw token once, with its name and expiry", async () => {
const res = await POST(req({ name: "Paperless workflow" }) as never);
const json = await res.json();
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
expect(json.data.name).toBe("Paperless workflow");
expect(json.data.expiresAt).toBeDefined();
});
it("expires by default rather than living forever", async () => {
await POST(req({ name: "Import script" }) as never);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
expect(opts.expiresInDays).toBe(365);
});
it("honours an explicit lifetime", async () => {
await POST(req({ name: "Import script", expiresInDays: 30 }) as never);
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
});
it("audits the mint with the token id and the scope", async () => {
await POST(req({ name: "Import script" }) as never);
expect(auditLog).toHaveBeenCalledWith(
"tokens.documents.create",
expect.objectContaining({
userId: USER.id,
details: expect.objectContaining({
tokenId: "token-1",
scope: DOCUMENTS_WRITE_SCOPE,
}),
}),
);
});
it("counts only live tokens carrying this scope", async () => {
// The whole shape of the query, asserted rather than assumed. Counting
// every row would let the `["*"]` tokens a login mints consume the budget
// and refuse a mint for a reason the person cannot see; counting revoked
// or expired rows would mean revoking never frees a slot.
await POST(req({ name: "Import script" }) as never);
const where = vi.mocked(prisma.apiToken.count).mock.calls[0][0]?.where;
expect(where).toMatchObject({
userId: USER.id,
revoked: false,
permissions: { has: DOCUMENTS_WRITE_SCOPE },
});
expect(where?.OR).toEqual([
{ expiresAt: null },
{ expiresAt: { gt: expect.any(Date) } },
]);
});
});
describe("when it refuses", () => {
it("403s while the operator has the API switched off", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(req({ name: "Import script" }) as never);
expect(res.status).toBe(403);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("429s past the mint rate limit, without minting", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(req({ name: "Import script" }) as never);
expect(res.status).toBe(429);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a missing name", async () => {
const res = await POST(req({}) as never);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a lifetime outside the permitted band", async () => {
const res = await POST(
req({ name: "Import script", expiresInDays: 4000 }) as never,
);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("409s at the live-token ceiling, without minting", async () => {
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
const res = await POST(req({ name: "Import script" }) as never);
// 409, not 429: a conflict with the account's current state, whose remedy
// is revoking rather than waiting. A 429 would say the opposite.
expect(res.status).toBe(409);
expect((await res.json()).meta?.errorCode).toBe(
"tokens.documents.ceiling_reached",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("still mints on the last free slot", async () => {
// The off-by-one that would make the ceiling nine. Worth pinning, because
// `>=` versus `>` is invisible in review and only one of them is right.
vi.mocked(prisma.apiToken.count).mockResolvedValue(9 as never);
const res = await POST(req({ name: "Import script" }) as never);
expect(res.status).toBe(201);
});
it("checks the ceiling only after the body validates", async () => {
// A malformed request should hear about the malformation, not the
// ceiling — and should not spend a query establishing it.
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
const res = await POST(req({}) as never);
expect(res.status).toBe(422);
expect(prisma.apiToken.count).not.toHaveBeenCalled();
});
it("ignores a scope the body tries to smuggle in", async () => {
// There is no `scope` field to validate, so an unknown key is simply not
// read — the permission array is a literal either way. Asserted because
// "the field does not exist" is the security property, and a schema change
// that added one would silently make this body meaningful.
await POST(
req({
name: "Import script",
scope: "read_write",
permissions: ["*"],
}) as never,
);
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
DOCUMENTS_WRITE_SCOPE,
]);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* `POST /api/tokens/documents` — mint a Bearer that can upload documents into
* the vault and do nothing else (#1038).
*
* The door another document system pushes through: a Paperless-ngx workflow
* posting a newly tagged document, or the import script copying an archive
* across. It is the measurement-ingest mint (`../measurements/route.ts`)
* copied for a second scope, and every argument made there holds here — read
* that file for the reasoning behind each guard. In short:
*
* - `permissions` is a literal, so no request shape reaches it;
* - `documents:write` is accepted by exactly one route, the upload, on the
* holder's own record, and the answer it gets back is a receipt rather than
* the stored row — it cannot list, read, download, edit or delete a
* document, reach the AI legs, or mint another token;
* - minting takes a cookie session, because a short-lived native access token
* must not be able to leave behind a credential that lives a year;
* - ten mints a minute and ten live tokens bound a runaway loop.
*
* No listing and no revoke of its own: `GET /api/tokens` lists every token
* with its permissions and `DELETE /api/tokens/[id]` revokes one.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { createDocumentTokenSchema } from "@/lib/validations/tokens";
/**
* Days a token lives when the caller names no lifetime. A year, like the
* measurement token: it is pasted into a workflow that runs unattended, where
* a quiet expiry surfaces as "new letters stopped arriving in the spring".
*/
const DEFAULT_EXPIRY_DAYS = 365;
/** Mints per user per minute. */
const MINT_RATE_LIMIT_MAX = 10;
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/** Live document tokens an account may hold at once; catches a loop. */
const MAX_LIVE_TOKENS = 10;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireCookieAuth();
annotate({ action: { name: "tokens.documents.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const rl = await checkRateLimit(
`tokens:documents:mint:${user.id}`,
MINT_RATE_LIMIT_MAX,
MINT_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many token mints, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createDocumentTokenSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// Counted live and per scope, after validation and before the create; the
// count-then-create race is bounded by the mint bucket above. See the
// measurement mint for the full argument.
const live = await prisma.apiToken.count({
where: {
userId: user.id,
revoked: false,
permissions: { has: DOCUMENTS_WRITE_SCOPE },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
if (live >= MAX_LIVE_TOKENS) {
annotate({
action: { name: "tokens.documents.create" },
meta: { outcome: "ceiling_reached", live_token_count: live },
});
return apiError(
`You already have ${MAX_LIVE_TOKENS} document tokens. Revoke one before creating another.`,
409,
{ errorCode: "tokens.documents.ceiling_reached" },
);
}
const issued = await issueApiToken({
userId: user.id,
name: parsed.data.name,
// A literal, never spread and never derived from the body. `issueApiToken`
// defaults to `["*"]` when this property is absent, which is why the unit
// suite asserts this array and not merely the 201.
permissions: [DOCUMENTS_WRITE_SCOPE],
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
});
await auditLog("tokens.documents.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { tokenId: issued.tokenId, scope: DOCUMENTS_WRITE_SCOPE },
});
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
return apiSuccess(
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
201,
);
});