fix(server): return 413 for an export bundle too large to serialize

## 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 <noreply@paperclip.ing>
This commit is contained in:
Waseem Ilyas 2026-09-10 23:14:11 +00:00
parent 4042eb1c48
commit 9787fe260d
2 changed files with 50 additions and 3 deletions

View File

@ -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({

View File

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