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:
parent
4042eb1c48
commit
9787fe260d
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue