Skip to content
Open
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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
103 changes: 103 additions & 0 deletions src/bulk-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 25 additions & 3 deletions src/routes/config.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -8,6 +8,22 @@ const allowedConfigKeys = [
"bulkMaxItems",
] as const;

type ConfigKey = (typeof allowedConfigKeys)[number];

const configBounds: Record<ConfigKey, { min: number; max?: number }> = {
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.
*/
Expand All @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions src/routes/services.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { Router, type Request, type Response } from "express";
import {
config,
servicesDisabled,
servicesMetadata,
servicesStore,
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/routes/usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from "express";
import { recordEvent } from "../events.js";
import {
config,
servicesDisabled,
servicesStore,
usageKey,
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 20 additions & 13 deletions src/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
rateLimitPerWindow: 60,
Expand Down