diff --git a/README.md b/README.md index 337ee4f..3fecf6b 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,13 @@ BASE_URL=http://localhost:3001 ``` Bulk registration is available at `POST /api/v1/services/bulk` with an - `items` array of 1-50 services. The endpoint keeps its partial-success - response contract: valid unique items are applied, invalid items report - `invalid_item`, and later occurrences of a duplicate `serviceId` in the same - batch report `duplicate_in_batch` without overwriting the first item. + `items` array controlled by the runtime `bulkMaxItems` config. The default + limit is 100 items, and `PATCH /api/v1/config` accepts `bulkMaxItems` values + from 1 to 1000. The same active limit applies to `POST /api/v1/usage/bulk`. + Bulk endpoints keep their partial-success response contract: valid unique + items are applied, invalid items report `invalid_item`, and later occurrences + of a duplicate `serviceId` in the same batch report `duplicate_in_batch` + without overwriting the first item. 2. Record usage for an agent. diff --git a/src/bulk-limit.test.ts b/src/bulk-limit.test.ts new file mode 100644 index 0000000..0588624 --- /dev/null +++ b/src/bulk-limit.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { + config, + servicesDisabled, + servicesMetadata, + servicesStore, + usageStore, +} from "./store/state.js"; + +const defaultConfig = { + rateLimitPerWindow: 60, + rateLimitWindowMs: 60_000, + bulkMaxItems: 100, + eventLogCap: 10_000, +}; + +beforeEach(() => { + servicesDisabled.clear(); + servicesMetadata.clear(); + servicesStore.clear(); + usageStore.clear(); + Object.assign(config, defaultConfig); +}); + +function usageItems(count: number) { + return Array.from({ length: count }, (_, i) => ({ + agent: `agent-${i}`, + serviceId: `svc-${i}`, + requests: 1, + })); +} + +function serviceItems(count: number) { + return Array.from({ length: count }, (_, i) => ({ + serviceId: `svc-${i}`, + priceStroops: i, + })); +} + +void describe("runtime bulkMaxItems limits", () => { + void it("applies a lowered bulkMaxItems limit to usage bulk writes immediately", async () => { + const app = createApp(); + + const patched = await request(app) + .patch("/api/v1/config") + .send({ bulkMaxItems: 2 }); + assert.strictEqual(patched.status, 200); + assert.strictEqual(patched.body.config.bulkMaxItems, 2); + + const overLimit = await request(app) + .post("/api/v1/usage/bulk") + .set("X-Request-Id", "usage-bulk-over-limit") + .send({ items: usageItems(3) }); + assert.strictEqual(overLimit.status, 400); + assert.deepStrictEqual(overLimit.body, { + error: "invalid_request", + message: "items must be a non-empty array of up to 2 entries", + requestId: "usage-bulk-over-limit", + }); + + const atLimit = await request(app) + .post("/api/v1/usage/bulk") + .send({ items: usageItems(2) }); + assert.strictEqual(atLimit.status, 201); + assert.strictEqual(atLimit.body.results.length, 2); + }); + + void it("applies a raised bulkMaxItems limit to services bulk writes", async () => { + const app = createApp(); + + const patched = await request(app) + .patch("/api/v1/config") + .send({ bulkMaxItems: 60 }); + assert.strictEqual(patched.status, 200); + assert.strictEqual(patched.body.config.bulkMaxItems, 60); + + const created = await request(app) + .post("/api/v1/services/bulk") + .send({ items: serviceItems(51) }); + assert.strictEqual(created.status, 201); + assert.strictEqual(created.body.results.length, 51); + assert.ok(created.body.results.every((result: { ok: boolean }) => result.ok)); + }); + + void it("rejects bulkMaxItems values above the guarded maximum", async () => { + const app = createApp(); + + const rejected = await request(app) + .patch("/api/v1/config") + .set("X-Request-Id", "bulk-max-too-high") + .send({ bulkMaxItems: 1001 }); + assert.strictEqual(rejected.status, 400); + assert.deepStrictEqual(rejected.body, { + error: "invalid_request", + message: "bulkMaxItems must be an integer between 1 and 1000", + requestId: "bulk-max-too-high", + }); + assert.strictEqual(config.bulkMaxItems, 100); + }); +}); diff --git a/src/routes/config.ts b/src/routes/config.ts index 83405ab..3dcf734 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from "express"; -import { config } from "../store/state.js"; +import { BULK_MAX_ITEMS_LIMIT, config } from "../store/state.js"; import { getRequestId } from "../types.js"; const allowedConfigKeys = [ @@ -8,6 +8,22 @@ const allowedConfigKeys = [ "bulkMaxItems", ] as const; +type ConfigKey = (typeof allowedConfigKeys)[number]; + +const configBounds: Record = { + rateLimitPerWindow: { min: 1 }, + rateLimitWindowMs: { min: 1 }, + bulkMaxItems: { min: 1, max: BULK_MAX_ITEMS_LIMIT }, +}; + +function configValidationMessage(key: ConfigKey): string { + const bounds = configBounds[key]; + if (bounds.max !== undefined) { + return `${key} must be an integer between ${bounds.min} and ${bounds.max}`; + } + return `${key} must be a positive integer`; +} + /** * Builds the runtime config router. */ @@ -24,10 +40,16 @@ export function createConfigRouter(): Router { for (const k of allowedConfigKeys) { if (k in updates) { const v = updates[k]; - if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { + const bounds = configBounds[k]; + if ( + typeof v !== "number" || + !Number.isInteger(v) || + v < bounds.min || + (bounds.max !== undefined && v > bounds.max) + ) { res.status(400).json({ error: "invalid_request", - message: `${k} must be a positive integer`, + message: configValidationMessage(k), requestId, }); return; diff --git a/src/routes/services.ts b/src/routes/services.ts index c606e5c..787ab7a 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; import { + config, servicesDisabled, servicesMetadata, servicesStore, @@ -38,14 +39,15 @@ function serviceReadShape( export function createServicesRouter(): Router { const router = Router(); - /** Registers up to 50 services while rejecting duplicate ids in the same batch. */ + /** Registers up to the runtime bulk limit while rejecting duplicate ids. */ router.post("/api/v1/services/bulk", (req: Request, res: Response) => { const requestId = getRequestId(req); const { items } = req.body ?? {}; - if (!Array.isArray(items) || items.length === 0 || items.length > 50) { + const limit = config.bulkMaxItems; + if (!Array.isArray(items) || items.length === 0 || items.length > limit) { res.status(400).json({ error: "invalid_request", - message: "items must be 1-50 entries", + message: `items must be a non-empty array of up to ${limit} entries`, requestId, }); return; diff --git a/src/routes/usage.ts b/src/routes/usage.ts index cd28a50..a8fe57f 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from "express"; import { recordEvent } from "../events.js"; import { + config, servicesDisabled, servicesStore, usageKey, @@ -81,10 +82,11 @@ export function createUsageRouter(): Router { router.post("/api/v1/usage/bulk", (req: Request, res: Response) => { const requestId = getRequestId(req); const { items } = req.body ?? {}; - if (!Array.isArray(items) || items.length === 0 || items.length > 100) { + const limit = config.bulkMaxItems; + if (!Array.isArray(items) || items.length === 0 || items.length > limit) { res.status(400).json({ error: "invalid_request", - message: "items must be a non-empty array of up to 100 entries", + message: `items must be a non-empty array of up to ${limit} entries`, requestId, }); return; diff --git a/src/services.test.ts b/src/services.test.ts index 1fe73cd..6d318a4 100644 --- a/src/services.test.ts +++ b/src/services.test.ts @@ -75,8 +75,9 @@ void describe("Services CRUD", () => { await createService(id, 42); const res = await request(app).get("/api/v1/services"); assert.strictEqual(res.status, 200); - const found = (res.body.services as { serviceId: string; priceStroops: number }[]) - .find((s) => s.serviceId === id); + const found = ( + res.body.services as { serviceId: string; priceStroops: number }[] + ).find((s) => s.serviceId === id); assert.ok(found, "service missing from list"); assert.strictEqual(found.priceStroops, 42); }); @@ -105,7 +106,9 @@ void describe("Services CRUD", () => { assert.strictEqual(first.status, 200); const etag = first.headers.etag as string; assert.ok(etag, "ETag header missing"); - const second = await request(app).get("/api/v1/services").set("If-None-Match", etag); + const second = await request(app) + .get("/api/v1/services") + .set("If-None-Match", etag); assert.strictEqual(second.status, 304); }); @@ -159,9 +162,7 @@ void describe("Services CRUD", () => { void it(`PATCH price rejects ${label} with 400`, async () => { const id = sid(); await createService(id); - const res = await request(app) - .patch(`/api/v1/services/${id}/price`) - .send(body); + const res = await request(app).patch(`/api/v1/services/${id}/price`).send(body); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error, "invalid_request"); }); @@ -279,9 +280,7 @@ void describe("Services CRUD", () => { void it(`PUT metadata rejects ${label} with 400`, async () => { const id = sid(); await createService(id); - const res = await request(app) - .put(`/api/v1/services/${id}/metadata`) - .send(body); + const res = await request(app).put(`/api/v1/services/${id}/metadata`).send(body); assert.strictEqual(res.status, 400); assert.strictEqual(res.body.error, "invalid_request"); }); @@ -331,9 +330,9 @@ void describe("POST /api/v1/services/bulk", () => { .post("/api/v1/services/bulk") .send({ items: [ - { serviceId: good, priceStroops: 10 }, // index 0 — valid - { serviceId: "", priceStroops: 5 }, // index 1 — invalid - { serviceId: sid(), priceStroops: -1 }, // index 2 — invalid + { serviceId: good, priceStroops: 10 }, // index 0 — valid + { serviceId: "", priceStroops: 5 }, // index 1 — invalid + { serviceId: sid(), priceStroops: -1 }, // index 2 — invalid ], }); assert.strictEqual(res.status, 201); @@ -350,7 +349,15 @@ void describe("POST /api/v1/services/bulk", () => { ["empty items array", { items: [] }], ["items not an array", { items: "bad" }], ["missing items key", {}], - ["items > 50", { items: Array.from({ length: 51 }, (_, i) => ({ serviceId: `s${i}`, priceStroops: 1 })) }], + [ + "items > default bulkMaxItems", + { + items: Array.from({ length: 101 }, (_, i) => ({ + serviceId: `s${i}`, + priceStroops: 1, + })), + }, + ], ] as const) { void it(`bulk rejects ${label} with 400`, async () => { const res = await request(app).post("/api/v1/services/bulk").send(body); diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..764236d 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -12,6 +12,9 @@ export type WebhookRecord = { url: string; events: string[]; createdAt: number } /** Mirrors the on-chain pause flag for write-gated endpoints. */ export const pauseState = { paused: false }; +/** Upper bound for runtime bulk request sizing to avoid memory-exhaustion batches. */ +export const BULK_MAX_ITEMS_LIMIT = 1_000; + /** Runtime-tunable in-memory configuration returned by /api/v1/config. */ export const config: Record = { rateLimitPerWindow: 60,