From 9787fe260d5028a5df8ddb3debed9e24dc6b55b7 Mon Sep 17 00:00:00 2001 From: Waseem Ilyas <1478353+Waseemilyas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:14:11 +0000 Subject: [PATCH] fix(server): return 413 for an export bundle too large to serialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The company export routes build the full bundle in memory and answer with res.json, so a company whose serialized bundle exceeds V8's max string length throws RangeError inside JSON.stringify and surfaces as an unhandled 500 > - This pull request serializes the result explicitly and maps the RangeError onto a 413 with a clear remediation hint, so the operator sees an export-size problem rather than a generic server crash > - The benefit is a diagnosable failure mode while the deeper streaming work stays a separate change ## Linked Issues or Issue Description Refs #11659 — covers the "at minimum" half: fail with a clear "export too large" error instead of an unhandled RangeError. Streaming or a server-side download handle remains follow-up work. ## What Changed - server/src/routes/companies.ts: added sendExportBundle, which serializes the bundle itself; on RangeError it throws a 413 export_too_large telling the caller to narrow the include set. Both POST /:companyId/export and POST /:companyId/exports use it. - server/src/__tests__/company-portability-routes.test.ts: a bundle whose serialization throws RangeError now returns 413 on both routes. ## Verification - pnpm --filter @paperclipai/server vitest run src/__tests__/company-portability-routes.test.ts — 43 tests pass. ## Risks - Low risk. The serialized bytes sent to the client are identical to res.json output for every bundle that fits; only the oversized case changes, from an unhandled 500 to a structured 413. ## Model Used - Anthropic Claude — SWE-2 Max agent via Devin CLI, tool use and code execution. Co-authored-by: Paperclip --- .../company-portability-routes.test.ts | 27 +++++++++++++++++++ server/src/routes/companies.ts | 26 +++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 9872cd2f6b..0c3683cab7 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -472,6 +472,33 @@ describe.sequential("company portability routes", () => { ); }); + it.sequential("returns 413 instead of an unhandled 500 when the export bundle cannot serialize", async () => { + // A bundle past V8's max string length throws RangeError inside + // JSON.stringify; a toJSON hook reproduces that failure deterministically. + mockCompanyPortabilityService.exportBundle.mockResolvedValue({ + ...createExportResult(), + toJSON() { + throw new RangeError("Invalid string length"); + }, + }); + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + isInstanceAdmin: true, + source: "session", + }); + + for (const path of [`/api/companies/${companyId}/export`, `/api/companies/${companyId}/exports`]) { + const res = await request(app).post(path).send(exportRequest); + + expect(res.status).toBe(413); + expect(res.body.details?.code).toBe("export_too_large"); + expect(res.body.error).toContain("export exceeds the maximum serializable response size"); + } + }); + it.sequential("allows board users to export through legacy and CEO-safe bundle routes", async () => { mockCompanyPortabilityService.exportBundle.mockResolvedValue(createExportResult()); const app = await createApp({ diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index dc048a4403..a0b92aecbb 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -34,7 +34,7 @@ import { type CompanyImportTransferPartUploadResult, type CompanyImportTransferStatus, } from "@paperclipai/shared/company-import-transfer"; -import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { badRequest, conflict, forbidden, notFound, payloadTooLarge, unprocessable } from "../errors.js"; import { PORTABLE_ZIP_UPLOAD_LIMIT_BYTES } from "../http/body-limits.js"; import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; @@ -274,6 +274,26 @@ export interface CompanyRoutesOptions { importTransferSpoolRoot?: string; } +function sendExportBundle(res: Response, result: unknown) { + // The whole bundle rides back as one JSON string; past V8's max string + // length JSON.stringify throws RangeError, which must not escape as a + // generic 500. Serialize explicitly so an oversized export fails with a + // clear, actionable error instead. + let payload: string; + try { + payload = JSON.stringify(result); + } catch (err) { + if (err instanceof RangeError) { + throw payloadTooLarge( + "Company export exceeds the maximum serializable response size. Retry with a narrower include set or a scoped issue export.", + { code: "export_too_large" }, + ); + } + throw err; + } + res.type("application/json").send(payload); +} + export function companyRoutes(db: Db, storage?: StorageService, options?: CompanyRoutesOptions) { const router = Router(); const importTransferSpoolRoot = @@ -529,7 +549,7 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan const body = companyPortabilityExportSchema.parse(req.body); const allowExternalInstructions = await assertExternalInstructionExportAllowed(req, companyId, body); const result = await portability.exportBundle(companyId, body, { allowExternalInstructions }); - res.json(result); + sendExportBundle(res, result); }); router.get("/:companyId/export/fidelity", async (req, res) => { @@ -1133,7 +1153,7 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan const body = companyPortabilityExportSchema.parse(req.body); const allowExternalInstructions = await assertExternalInstructionExportAllowed(req, companyId, body); const result = await portability.exportBundle(companyId, body, { allowExternalInstructions }); - res.json(result); + sendExportBundle(res, result); }); router.post("/:companyId/imports/preview", async (req, res) => {