release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -106,6 +106,11 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
DOCUMENT_UPLOAD_PER_USER_CONCURRENCY,
} from "@/lib/documents/upload-policy";
const post = POST as unknown as (r: Request) => Promise<Response>;
const get = GET as unknown as (r: Request) => Promise<Response>;
@@ -311,6 +316,96 @@ describe("POST /api/documents/inbound (vault upload)", () => {
expect(res.status).toBe(404);
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects before reading the body when the user's upload slots are full", async () => {
const releases = Array.from(
{ length: DOCUMENT_UPLOAD_PER_USER_CONCURRENCY },
() => acquireDocumentUploadSlot("user-1"),
);
let bodyRead = false;
const unreadBody = new ReadableStream<Uint8Array>(
{
pull(controller) {
bodyRead = true;
controller.error(new Error("body must not be read"));
},
},
{ highWaterMark: 0 },
);
const request = new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: unreadBody,
duplex: "half",
} as RequestInit & { duplex: "half" });
try {
const res = await post(request);
expect(res.status).toBe(429);
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
expect(bodyRead).toBe(false);
expect(txCreate).not.toHaveBeenCalled();
} finally {
for (const release of releases) release!();
}
});
it("rejects before reading the body when process-wide upload slots are full", async () => {
const releases = Array.from(
{ length: DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY },
(_, index) => acquireDocumentUploadSlot(`other-user-${index}`),
);
try {
const res = await post(mkUpload());
expect(res.status).toBe(429);
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
expect(txCreate).not.toHaveBeenCalled();
} finally {
for (const release of releases) release!();
}
});
it("releases an upload slot after an invalid multipart body", async () => {
const invalid = new Request("http://localhost/api/documents/inbound", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=incomplete" },
body: "--incomplete\r\n",
});
const invalidResponse = await post(invalid);
expect(invalidResponse.status).toBe(400);
const held = acquireDocumentUploadSlot("user-1");
expect(held).not.toBeNull();
try {
const validResponse = await post(mkUpload());
expect(validResponse.status).toBe(201);
} finally {
held!();
}
});
it("does not make another whole-body Blob copy before multipart parsing", async () => {
const request = mkUpload();
const NativeBlob = globalThis.Blob;
let blobConstructions = 0;
class CountingBlob extends NativeBlob {
constructor(parts?: BlobPart[], options?: BlobPropertyBag) {
blobConstructions += 1;
super(parts, options);
}
}
globalThis.Blob = CountingBlob;
try {
const res = await post(request);
expect(res.status).toBe(201);
expect(blobConstructions).toBe(0);
} finally {
globalThis.Blob = NativeBlob;
}
});
});
describe("GET /api/documents/inbound (list)", () => {
+45 -7
View File
@@ -27,7 +27,7 @@ import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -45,6 +45,7 @@ import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import {
acquireDocumentUploadSlot,
detectDocumentType,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
@@ -76,6 +77,7 @@ const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
* exact per-file cap is re-enforced on the extracted file bytes below.
*/
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
function isoDateToUtc(value: string): Date {
@@ -140,10 +142,11 @@ async function duplicateResponse(
);
}
/** POST — store an uploaded document encrypted at rest. No extraction. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
/** Process one admitted upload. The caller owns and releases its memory slot. */
async function processUpload(
request: Request,
user: AuthContext["user"],
): Promise<Response> {
// 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");
@@ -176,14 +179,25 @@ async function postUpload(request: Request): Promise<Response> {
let formData: FormData;
try {
const bytes = await readBoundedBody(request.body, bodyCap);
formData = await new Response(new Blob([bytes]), {
const bytes = await readBoundedBody(request.body, bodyCap, {
signal: request.signal,
deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS,
});
formData = await new Request(request.url, {
method: "POST",
headers: { "content-type": request.headers.get("content-type") ?? "" },
body: bytes,
}).formData();
} catch (err) {
if (err instanceof BodyTooLargeError) {
return fileTooLarge(limits.maxFileBytes);
}
if (err instanceof DOMException && err.name === "AbortError") {
return apiError("Upload body read timed out.", 408, {
errorCode: "documents.inbound.uploadTimeout",
reason: "uploadTimeout",
});
}
return apiError("Invalid multipart body", 400);
}
@@ -397,6 +411,30 @@ async function postUpload(request: Request): Promise<Response> {
);
}
/** Authenticate and reserve memory capacity before any request-body read. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
const releaseUploadSlot = acquireDocumentUploadSlot(user.id);
if (!releaseUploadSlot) {
const response = apiError(
"Too many uploads are already in progress. Try again shortly.",
429,
{
errorCode: "documents.inbound.uploadBusy",
reason: "uploadBusy",
},
);
response.headers.set("Retry-After", "1");
return response;
}
try {
return await processUpload(request, user);
} finally {
releaseUploadSlot();
}
}
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
/** GET — list the caller's documents (search / filter / sort / paginate). */