diff --git a/backend/src/app.ts b/backend/src/app.ts index 617e428e..7192b745 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import { sandboxMiddleware } from "./middleware/sandbox.middleware.js"; import { globalRateLimiter, healthRateLimiter } from "./middleware/rate-limiter.middleware.js"; import { metricsMiddleware } from "./middleware/metrics.middleware.js"; import { requestIdMiddleware } from "./middleware/requestId.js"; +import { bigIntSafeJsonMiddleware } from "./lib/serialize.js"; import v1Routes from "./routes/v1/index.js"; import healthRoutes from "./routes/health.routes.js"; import metricsRoutes from "./routes/metrics.routes.js"; @@ -116,6 +117,10 @@ const BULK_JSON_PATHS = [ app.use(BULK_JSON_PATHS, express.json({ limit: "1mb" })); app.use(express.json({ limit: "100kb" })); +// BigInt-safe JSON responses (Issue #1493): Prisma bigint columns must be +// emitted as decimal strings, never throw in res.json(). +app.use(bigIntSafeJsonMiddleware); + // Sandbox mode detection (before versioning) app.use(sandboxMiddleware); diff --git a/backend/src/lib/serialize.ts b/backend/src/lib/serialize.ts new file mode 100644 index 00000000..b309dd3c --- /dev/null +++ b/backend/src/lib/serialize.ts @@ -0,0 +1,58 @@ +/** + * BigInt-safe JSON serialization for API responses (Issue #1493). + * + * Prisma maps `bigint` columns (stream ids, i128 amounts) to JavaScript + * `BigInt`, which `JSON.stringify` cannot encode — it throws + * `TypeError: Do not know how to serialize a BigInt`. Relying on a global + * `BigInt.prototype.toJSON` patch is fragile because it only takes effect when + * the module installing it happens to be imported, so this module provides an + * explicit replacer plus an Express middleware that applies it to every + * `res.json()` call, including error bodies. + */ +import type { NextFunction, Request, Response } from "express"; + +/** + * `JSON.stringify` replacer that encodes `bigint` values as decimal strings. + * + * Strings are used rather than numbers because u64/i128 values routinely exceed + * `Number.MAX_SAFE_INTEGER`, where a number would silently lose precision. All + * other values are returned unchanged. + */ +export function bigIntSafeReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} + +/** + * `JSON.stringify` with BigInt support. Returns `undefined` (matching + * `JSON.stringify`) for values that cannot be serialized, such as a bare + * `undefined`. + */ +export function stringifyJson(value: unknown): string | undefined { + return JSON.stringify(value, bigIntSafeReplacer); +} + +/** + * Express middleware that routes every `res.json()` call through + * `stringifyJson`, so a BigInt anywhere in the response body — including inside + * nested objects and arrays — is emitted as a decimal string instead of + * throwing. + * + * Mount this on the app before the routers (and before `errorHandler`) so that + * every controller response and every error payload is covered. + */ +export function bigIntSafeJsonMiddleware( + _req: Request, + res: Response, + next: NextFunction, +): void { + const jsonWithBigIntSupport = (body?: unknown): Response => { + const json = stringifyJson(body); + if (!res.getHeader("Content-Type")) { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + } + return res.send(json); + }; + + res.json = jsonWithBigIntSupport as Response["json"]; + next(); +} diff --git a/backend/tests/serialize.test.ts b/backend/tests/serialize.test.ts new file mode 100644 index 00000000..e90d1e8d --- /dev/null +++ b/backend/tests/serialize.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { + bigIntSafeJsonMiddleware, + bigIntSafeReplacer, + stringifyJson, +} from '../src/lib/serialize.js'; + +describe('BigInt-safe serializer (#1493)', () => { + it('encodes a top-level BigInt as a decimal string', () => { + expect(stringifyJson({ streamId: 123456789012345678901234567890n })).toBe( + '{"streamId":"123456789012345678901234567890"}', + ); + }); + + it('serializes nested objects and arrays containing BigInts', () => { + const payload = { + streams: [ + { streamId: 42n, meta: { depositedAmount: 9007199254740993n } }, + { streamId: 7n, withdrawable: [1n, 2n, 3n] }, + ], + total: 2, + }; + + const parsed = JSON.parse(stringifyJson(payload)!); + + expect(parsed.streams[0].streamId).toBe('42'); + expect(parsed.streams[0].meta.depositedAmount).toBe('9007199254740993'); + expect(parsed.streams[1].withdrawable).toEqual(['1', '2', '3']); + expect(parsed.total).toBe(2); + }); + + it('leaves plain values untouched', () => { + const payload = { + address: 'GABC', + amount: 1.5, + active: true, + pausedAt: null, + tags: ['a', 1], + }; + + expect(JSON.parse(stringifyJson(payload)!)).toEqual(payload); + }); + + it('does not depend on a global BigInt.prototype.toJSON patch', () => { + // `stream-id.ts` installs such a patch as a side effect, but this module + // must work even when the patch was never applied (the module was not + // imported by any earlier request handler). + expect((BigInt.prototype as unknown as { toJSON?: unknown }).toJSON).toBeUndefined(); + expect(stringifyJson({ value: 7n })).toBe('{"value":"7"}'); + }); + + it('returns undefined for un-serializable input, like JSON.stringify', () => { + expect(stringifyJson(undefined)).toBeUndefined(); + }); + + it('passes unknown keys through the replacer unchanged', () => { + expect(bigIntSafeReplacer('key', 'str')).toBe('str'); + expect(bigIntSafeReplacer('key', 5n)).toBe('5'); + }); + + it('routes every res.json() call through the serializer', async () => { + const app = express(); + app.use(bigIntSafeJsonMiddleware); + app.get('/big-int', (_req, res) => { + res.json({ streamId: 9007199254740993n, nested: [{ value: 5n }] }); + }); + + const response = await request(app).get('/big-int'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.body).toEqual({ + streamId: '9007199254740993', + nested: [{ value: '5' }], + }); + }); +});