mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user