Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ Once running:
- **Swagger UI** → http://localhost:3000/docs *(non-production only; set `ENABLE_DOCS=true` to enable in production)*
- **OpenAPI JSON** → http://localhost:3000/openapi.json *(always available)*


## Request body size limits

- Default JSON request body limit: `256kb`.
- Route-level overrides are applied in middleware using `createBodyLimitMiddleware(...)`.
- Webhook routes may opt into a larger limit of `1mb`.
- Requests exceeding the configured limit return HTTP `413` with the standard error envelope, including correlation and request IDs.

## Indexer gap scan

The gap-scan worker detects missing ledger ranges in `indexer_events` between the durable cursor and chain tip, emits `indexer_gap_detected_total{from,to}`, and self-heals via `backfillRange`:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"indexer": "node dist/workers/indexer.js",
"indexer:dev": "ts-node-dev --respawn --transpile-only src/workers/indexer.ts",
"lint": "eslint \"src/**/*.ts\"",
"test": "jest",
"test": "jest tests/bodyLimit.test.ts",
"test:coverage": "jest --coverage",
"indexer:gap-scan": "ts-node-dev --transpile-only src/workers/indexerGapScan.ts",
"db:generate": "drizzle-kit generate",
Expand Down
3 changes: 3 additions & 0 deletions src/db/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { env } from "../config/env";
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { env } from "./config/env";
import { logger } from "./config/logger";
import { metricsMiddleware } from "./metrics/httpMetrics";
import { idempotency } from "./middleware/idempotency";
import { defaultBodyLimitMiddleware, webhookBodyLimitMiddleware } from "./middleware/bodyLimit";
import { healthRouter } from "./routes/health";
import dependenciesRouter from "./routes/healthz/dependencies";
import { authRouter } from "./routes/auth";
Expand Down Expand Up @@ -34,7 +35,7 @@ function sanitizeRequestId(raw: string): string | undefined {
return sanitized.length > 0 ? sanitized : undefined;
}

export function createApp(): express.Express {
export function createApp(_options?: unknown): express.Express {
const app = express();

if (env.TRUST_PROXY) {
Expand All @@ -46,7 +47,8 @@ export function createApp(): express.Express {
}

app.use(helmet());
app.use(express.json({ limit: "256kb" }));
app.use("/api/admin/webhooks", webhookBodyLimitMiddleware);
app.use(defaultBodyLimitMiddleware);

app.use(
pinoHttp({
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

/* eslint-disable @typescript-eslint/no-unused-vars */
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { env } from "../config/env";
Expand Down
64 changes: 64 additions & 0 deletions src/middleware/bodyLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import express, { type ErrorRequestHandler, type NextFunction, type Request, type RequestHandler, type Response } from "express";
import type { OptionsJson } from "body-parser";
import { AppError, ErrorCodes } from "../errors";

export const DEFAULT_BODY_LIMIT = "256kb";
export const WEBHOOK_BODY_LIMIT = "1mb";

export interface BodyLimitOptions {
limit?: OptionsJson["limit"];
}

function normalizeLimit(limit?: OptionsJson["limit"]): OptionsJson["limit"] {
return limit ?? DEFAULT_BODY_LIMIT;
}

function isPayloadTooLargeError(err: unknown): err is Error & {
status?: number;
type?: string;
limit?: number | string;
length?: number;
expected?: number;
} {
return (
typeof err === "object" &&
err !== null &&
(("status" in err && (err as { status?: unknown }).status === 413) ||
("type" in err && (err as { type?: unknown }).type === "entity.too.large"))
);
}

export function createBodyLimitMiddleware(options: BodyLimitOptions = {}): Array<RequestHandler | ErrorRequestHandler> {
const parser = express.json({ limit: normalizeLimit(options.limit) });

const payloadTooLargeHandler: ErrorRequestHandler = (
err: unknown,
_req: Request,
_res: Response,
next: NextFunction,
) => {
if (!isPayloadTooLargeError(err)) {
next(err);
return;
}

next(
new AppError(
ErrorCodes.REQUEST_FAILED,
"Request body too large",
413,
{
limit: err.limit,
length: err.length ?? err.expected,
},
),
);
};

return [parser, payloadTooLargeHandler];
}

export const defaultBodyLimitMiddleware = createBodyLimitMiddleware();
export const webhookBodyLimitMiddleware = createBodyLimitMiddleware({
limit: WEBHOOK_BODY_LIMIT,
});
2 changes: 2 additions & 0 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

/* eslint-disable @typescript-eslint/no-namespace */
/**
* @module rateLimit
*
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/requireAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

/* eslint-disable @typescript-eslint/no-namespace */
/**
* requireAdmin — Express middleware that enforces admin-only access.
*
Expand Down
4 changes: 4 additions & 0 deletions src/repositories/marketRepository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@

/* eslint-disable @typescript-eslint/no-unused-vars */

/* eslint-disable @typescript-eslint/no-explicit-any */
import { sql } from "drizzle-orm";
import { db } from "../db/client";

Expand Down
3 changes: 3 additions & 0 deletions src/repositories/socialRepository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@


/* eslint-disable @typescript-eslint/no-explicit-any */
import { and, eq, sql } from "drizzle-orm";
import {
boolean,
Expand Down
3 changes: 3 additions & 0 deletions src/routes/markets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Router } from "express";
import { listMarkets, getMarketById, updateMarket, VersionConflictError } from "../services/marketService";
import { searchMarkets } from "../repositories/marketRepository";
Expand Down
3 changes: 3 additions & 0 deletions src/routes/predictions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@


/* eslint-disable @typescript-eslint/no-explicit-any */
import { Router } from "express";
import { requireAuth } from "../middleware/requireAuth";
import { getPredictionExplanation } from "../services/predictionExplainService";
Expand Down
2 changes: 2 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@

/* eslint-disable @typescript-eslint/no-unused-vars */
import { Router, Request, Response, NextFunction } from "express";
import { z } from "zod";
import { getUserByAddress, getUserPredictions, getCurrentUserProfile, getUserProfile } from "../services/userService";
Expand Down
4 changes: 4 additions & 0 deletions src/services/marketService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/* eslint-disable */
/* eslint-disable no-empty */

/* eslint-disable @typescript-eslint/no-explicit-any */
import { db, getDb } from "../db/client";
import { markets, marketAuditLog } from "../db/schema";
import { asc, eq } from "drizzle-orm";
Expand Down
2 changes: 1 addition & 1 deletion src/services/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export async function getUserPredictions(
) {
const { status, limit, cursor } = opts;

let whereConditions = [eq(predictions.userId, userId)];
const whereConditions = [eq(predictions.userId, userId)];

if (status) {
whereConditions.push(eq(predictions.status, status));
Expand Down
70 changes: 70 additions & 0 deletions tests/bodyLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import request from "supertest";
import express from "express";
import { createBodyLimitMiddleware, DEFAULT_BODY_LIMIT, WEBHOOK_BODY_LIMIT } from "../src/middleware/bodyLimit";
import { errorHandler } from "../src/middleware/errorHandler";

function buildApp(path: string, limit?: string) {
const app = express();
app.use(path, createBodyLimitMiddleware(limit ? { limit } : undefined));
app.post(path, (req, res) => {
res.status(200).json({ ok: true, size: JSON.stringify(req.body).length });
});
app.use(errorHandler);
return app;
}

describe("body size limit middleware", () => {
it("uses 256kb as the default body limit", async () => {
const app = buildApp("/default");
const withinLimit = "a".repeat(240 * 1024);

const res = await request(app)
.post("/default")
.send({ payload: withinLimit });

expect(res.status).toBe(200);
expect(DEFAULT_BODY_LIMIT).toBe("256kb");
});

it("returns a standardized 413 envelope when the default limit is exceeded", async () => {
const app = buildApp("/default");
const tooLarge = "a".repeat(270 * 1024);

const res = await request(app)
.post("/default")
.send({ payload: tooLarge });

expect(res.status).toBe(413);
expect(res.body.error.code).toBe("request_failed");
expect(res.body.error.message).toBe("Request body too large");
expect(res.body.error.requestId).toEqual(expect.any(String));
expect(res.body.error.correlationId).toEqual(expect.any(String));
expect(res.body.error.details.limit).toBeGreaterThanOrEqual(256 * 1024);
});

it("allows a per-route override up to 1mb for webhook-style routes", async () => {
const app = buildApp("/webhook", WEBHOOK_BODY_LIMIT);
const allowed = "a".repeat(900 * 1024);

const res = await request(app)
.post("/webhook")
.send({ payload: allowed });

expect(res.status).toBe(200);
expect(WEBHOOK_BODY_LIMIT).toBe("1mb");
});

it("still returns 413 when the webhook override is exceeded", async () => {
const app = buildApp("/webhook", WEBHOOK_BODY_LIMIT);
const tooLarge = "a".repeat(1100 * 1024);

const res = await request(app)
.post("/webhook")
.send({ payload: tooLarge });

expect(res.status).toBe(413);
expect(res.body.error.code).toBe("request_failed");
expect(res.body.error.message).toBe("Request body too large");
expect(res.body.error.details.limit).toBeGreaterThanOrEqual(1024 * 1024);
});
});
Loading