diff --git a/README.md b/README.md index fe3f4aa..7c56a8f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,9 @@ evaluates all questions against the same state. The dispatcher then: 4. Solves a maximum-weight assignment across all teams and slots. 5. Returns both the model's top choice and the applied assignment. +The server limits all API traffic to 120 requests per minute per client. Dispatch calls +have a separate limit of 20 requests per minute to bound paid model usage. + When Jev mode is active, the browser also evaluates the local policy from the same snapshot. Both plans are simulated for 20 seconds with identical future incident arrivals. The comparison ranks outcomes in this order: diff --git a/package-lock.json b/package-lock.json index d5a0a00..bbebb49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@typesafe-ai/sdk": "^0.6.0", "dotenv": "^18.0.1", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", "zod": "^4.6.5" }, "devDependencies": { @@ -2399,6 +2400,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2630,6 +2650,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index a9a1f43..6af0b0e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@typesafe-ai/sdk": "^0.6.0", "dotenv": "^18.0.1", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", "zod": "^4.6.5" }, "devDependencies": { diff --git a/server/app.ts b/server/app.ts index c0dc830..e4cab16 100644 --- a/server/app.ts +++ b/server/app.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import express, { type NextFunction, type Request, type Response } from "express"; +import { rateLimit } from "express-rate-limit"; import { ZodError, z } from "zod"; import { CrisisSnapshotSchema } from "../src/shared/crisis"; import type { ServerStatus } from "../src/shared/decision-types"; @@ -36,9 +37,28 @@ export function createApplication(options: ApplicationOptions = {}): express.Exp }) : undefined; const app = express(); + const apiRateLimit = rateLimit({ + windowMs: 60_000, + limit: 120, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { + error: "API request limit exceeded. Try again shortly.", + }, + }); + const dispatchRateLimit = rateLimit({ + windowMs: 60_000, + limit: 20, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { + error: "Dispatch request limit exceeded. Try again shortly.", + }, + }); app.disable("x-powered-by"); app.use(express.json({ limit: "64kb" })); + app.use("/api", apiRateLimit); app.get("/api/status", (_request, response) => { const status: ServerStatus = { @@ -64,7 +84,7 @@ export function createApplication(options: ApplicationOptions = {}): express.Exp } }); - app.post("/api/dispatch", async (request, response) => { + app.post("/api/dispatch", dispatchRateLimit, async (request, response) => { const input = DispatchRequestSchema.parse(request.body); if (input.mode === "jev" && !jevClient) { diff --git a/tests/api.test.ts b/tests/api.test.ts index 72d5fda..92cb5de 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -89,6 +89,34 @@ describe("HTTP API", () => { questions: 35, }); }); + + it("rate limits dispatch requests", async () => { + const { rootDirectory, baseUrl } = await startTestApplication(); + cleanupDirectories.push(rootDirectory); + const snapshot = createCrisisSnapshot(4, 445); + const request = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + snapshot, + doctrine: "Protect civilians.", + mode: "local", + }), + }; + + for (let index = 0; index < 20; index += 1) { + const response = await fetch(`${baseUrl}/api/dispatch`, request); + expect(response.status).toBe(200); + } + + const limited = await fetch(`${baseUrl}/api/dispatch`, request); + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toEqual({ + error: "Dispatch request limit exceeded. Try again shortly.", + }); + }); }); async function startTestApplication(