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) => {