import type { NextFunction, Request, Response } from "express"; import { v4 as uuid } from "uuid"; import { API_PREFIX, MAX_CSV_IMPORT_BODY_BYTES } from "../constants.js"; /** * T-165 — route-scoped body parser for `POST /databases/:id/import/csv`. * * The CSV import legitimately carries up to a 2 MB JSON body (a 10k-row * file), above the global 256 KB JSON ceiling the webhook contract set as * the app-wide limit. Nest mounts its parsers at `init()` on the SAME * express stack `use()` appends to, so this middleware must be registered * BEFORE the `app.useBodyParser("json", …)` calls in main.ts (and the * integration test helper) to run first. * * It buffers only the import route (everything else falls through untouched * to the 256 KB parser), 413s past `MAX_CSV_IMPORT_BODY_BYTES`, and sets * `req.body`/`req.rawBody` + `req._body = true` so the downstream global * parser skips (body-parser short-circuits on `req._body`). The 413/400 * responses use the canonical `{ error: { code, message, requestId } }` * envelope directly (no throw — we run ahead of the Nest pipeline, so the * exception filter is not in scope). */ const IMPORT_PATH = new RegExp(`^/${API_PREFIX.replace(/\//g, "\\/")}/databases/[^/]+/import/csv$`); function envelope(req: Request, res: Response, code: string, status: number, message: string): void { const requestId = (req.headers["x-request-id"] as string | undefined) ?? uuid(); req.id = requestId; res.setHeader("x-request-id", requestId); res.status(status).json({ error: { code, message, requestId } }); } export function csvImportBodyParser(req: Request, res: Response, next: NextFunction): void { if (req.method !== "POST" || !IMPORT_PATH.test(req.path)) { next(); return; } // Fast path: a declared Content-Length over the cap 413s without buffering. const declared = Number(req.headers["content-length"] ?? 0); if (declared > MAX_CSV_IMPORT_BODY_BYTES) { req.resume(); envelope(req, res, "PAYLOAD_TOO_LARGE", 413, `CSV import body exceeds the ${MAX_CSV_IMPORT_BODY_BYTES} byte limit.`); return; } const chunks: Buffer[] = []; let size = 0; let done = false; const finish = (err?: unknown): void => { if (done) return; done = true; if (err) { next(err); return; } const raw = Buffer.concat(chunks); req.rawBody = raw; try { req.body = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; } catch { envelope(req, res, "VALIDATION_ERROR", 400, "Request body must be valid JSON."); return; } (req as unknown as Record)._body = true; // stop the downstream 256 KB parser next(); }; req.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_CSV_IMPORT_BODY_BYTES) { done = true; req.resume(); envelope(req, res, "PAYLOAD_TOO_LARGE", 413, `CSV import body exceeds the ${MAX_CSV_IMPORT_BODY_BYTES} byte limit.`); return; } chunks.push(chunk); }); req.on("end", () => finish()); req.on("error", (err) => finish(err)); }