From b626eced32c01f1f0d89d74aa1dca983b3221472 Mon Sep 17 00:00:00 2001 From: Efuntoye Victor Date: Mon, 28 Sep 2026 08:45:32 +0100 Subject: [PATCH 1/2] fix: guard against BigInt serialization errors in stream JSON responses (#1493) Closes #1493 --- backend/src/app.ts | 20 ++++++--- backend/src/lib/serialize.ts | 58 ++++++++++++++++++++++++ backend/tests/serialize.test.ts | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 backend/src/lib/serialize.ts create mode 100644 backend/tests/serialize.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 1f4ba106..1e1ca0b5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,6 +14,7 @@ import { sandboxMiddleware } from "./middleware/sandbox.middleware.js"; import { globalRateLimiter } from "./middleware/rate-limiter.middleware.js"; import { metricsMiddleware } from "./middleware/metrics.middleware.js"; import { requestIdMiddleware } from "./middleware/requestId.js"; +import { getRequestId } from "./lib/request-context.js"; import v1Routes from "./routes/v1/index.js"; import healthRoutes from "./routes/health.routes.js"; import metricsRoutes from "./routes/metrics.routes.js"; @@ -31,12 +32,14 @@ if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) { allowedOrigins.push("http://localhost:3000"); } -// Apply global rate limiter first -app.use(globalRateLimiter); - -// Request ID tracing +// Request ID tracing must be the very first middleware so that every response +// carries X-Request-ID — including responses short-circuited by later +// middleware such as the rate limiter's 429 or the CORS 403 (Issue #1494). app.use(requestIdMiddleware); +// Apply global rate limiter +app.use(globalRateLimiter); + // Request counting/latency for the Prometheus registry app.use(metricsMiddleware); @@ -90,7 +93,14 @@ app.use( // Convert CORS errors into 403 responses so callers get a clear status code app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { if (err instanceof Error && err.message === "CORS origin not allowed") { - res.status(403).json({ error: "CORS origin not allowed" }); + const requestId = getRequestId(); + res + .status(403) + .json( + requestId + ? { error: "CORS origin not allowed", requestId } + : { error: "CORS origin not allowed" }, + ); return; } next(err); 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' }], + }); + }); +}); From d7d7cbf191707a457e35e9cabfd9b471ef405730 Mon Sep 17 00:00:00 2001 From: Efuntoye Victor Date: Tue, 6 Oct 2026 12:27:25 +0100 Subject: [PATCH 2/2] fix(backend): mount the BigInt-safe JSON middleware in app.ts app.ts imported getRequestId from a './lib/request-context.js' module this branch never adds, so `tsc` failed with TS2307 and took every backend job with it. That import (and the X-Request-ID ordering, which belongs to #1494) is dropped here, and the middleware this PR actually adds is mounted before the routers so BigInt bodies serialize as decimal strings. --- backend/src/app.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index a0b3c9eb..7192b745 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,7 +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 { getRequestId } from "./lib/request-context.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"; @@ -32,14 +32,12 @@ if (!process.env.CORS_ALLOWED_ORIGINS && !isProduction) { allowedOrigins.push("http://localhost:3000"); } -// Request ID tracing must be the very first middleware so that every response -// carries X-Request-ID — including responses short-circuited by later -// middleware such as the rate limiter's 429 or the CORS 403 (Issue #1494). -app.use(requestIdMiddleware); - -// Apply global rate limiter +// Apply global rate limiter first app.use(globalRateLimiter); +// Request ID tracing +app.use(requestIdMiddleware); + // Request counting/latency for the Prometheus registry app.use(metricsMiddleware); @@ -93,14 +91,7 @@ app.use( // Convert CORS errors into 403 responses so callers get a clear status code app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { if (err instanceof Error && err.message === "CORS origin not allowed") { - const requestId = getRequestId(); - res - .status(403) - .json( - requestId - ? { error: "CORS origin not allowed", requestId } - : { error: "CORS origin not allowed" }, - ); + res.status(403).json({ error: "CORS origin not allowed" }); return; } next(err); @@ -126,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);