From f777186c5a310a09738bf3f1cd6a46d3d403d649 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Tue, 23 Jun 2026 23:38:03 -0400 Subject: [PATCH] fix: enforce bulk usage validation parity --- README.md | 8 +++ src/routes/usage.ts | 123 +++++++++++++++++++++++++-------------- src/usage-bulk.test.ts | 128 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 43 deletions(-) create mode 100644 src/usage-bulk.test.ts diff --git a/README.md b/README.md index fa721c7..169ac77 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,14 @@ agentpay-backend/ | `npm run dev` | Run with ts-node | | `npm start` | Run production build | +## Usage validation + +Single and bulk usage writes share the same identifier and request-count +validation. `agent` must be non-empty and at most 256 characters, `serviceId` +must be non-empty and at most 128 characters, and `requests` must be a positive +integer. Bulk writes preserve partial success but report disabled services as +`service_disabled` and malformed rows as `invalid_item`. + ## CI/CD On push/PR to `main`, GitHub Actions runs: diff --git a/src/routes/usage.ts b/src/routes/usage.ts index 13b5415..6aa97d8 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -15,6 +15,66 @@ type BulkUsageResult = { error?: string; }; +type ValidUsageItem = { + agent: string; + serviceId: string; + requests: number; +}; + +type UsageInput = { + agent?: unknown; + serviceId?: unknown; + requests?: unknown; +}; + +type UsageValidationError = + | "invalid_agent" + | "invalid_serviceId" + | "invalid_requests" + | "service_disabled"; + +type UsageValidationResult = + | ({ ok: true } & ValidUsageItem) + | { ok: false; error: UsageValidationError }; + +/** + * Validates usage writes for both single and bulk ingestion paths so identifier + * length caps and disabled-service checks cannot drift between endpoints. + */ +function validateUsageItem(input: UsageInput): UsageValidationResult { + const { agent, serviceId, requests } = input; + if (typeof agent !== "string" || agent.length === 0 || agent.length > 256) { + return { ok: false, error: "invalid_agent" }; + } + if ( + typeof serviceId !== "string" || + serviceId.length === 0 || + serviceId.length > 128 + ) { + return { ok: false, error: "invalid_serviceId" }; + } + if (typeof requests !== "number" || !Number.isInteger(requests) || requests <= 0) { + return { ok: false, error: "invalid_requests" }; + } + if (servicesDisabled.has(serviceId)) { + return { ok: false, error: "service_disabled" }; + } + return { ok: true, agent, serviceId, requests }; +} + +function usageValidationMessage(error: UsageValidationError, serviceId?: string): string { + switch (error) { + case "invalid_agent": + return "agent must be a non-empty string up to 256 chars"; + case "invalid_serviceId": + return "serviceId must be a non-empty string up to 128 chars"; + case "invalid_requests": + return "requests must be a positive integer"; + case "service_disabled": + return `service ${serviceId ?? "unknown"} is currently disabled`; + } +} + /** * Builds usage, billing, settlement, and agent rollup routes. */ @@ -22,47 +82,25 @@ export function createUsageRouter(): Router { const router = Router(); router.post("/api/v1/usage", (req: Request, res: Response) => { - const { agent, serviceId, requests } = req.body ?? {}; + const body = (req.body ?? {}) as UsageInput; + const result = validateUsageItem(body); const requestId = getRequestId(req); - if (typeof agent !== "string" || agent.length === 0 || agent.length > 256) { - res.status(400).json({ - error: "invalid_request", - message: "agent must be a non-empty string up to 256 chars", - requestId, - }); - return; - } - if ( - typeof serviceId !== "string" || - serviceId.length === 0 || - serviceId.length > 128 - ) { - res.status(400).json({ - error: "invalid_request", - message: "serviceId must be a non-empty string up to 128 chars", - requestId, - }); - return; - } - if (typeof requests !== "number" || !Number.isInteger(requests) || requests <= 0) { - res.status(400).json({ - error: "invalid_request", - message: "requests must be a positive integer", - requestId, - }); - return; - } - - if (servicesDisabled.has(serviceId)) { - res.status(409).json({ - error: "service_disabled", - message: `service ${serviceId} is currently disabled`, + if (!result.ok) { + const status = result.error === "service_disabled" ? 409 : 400; + const error = result.error === "service_disabled" ? "service_disabled" : "invalid_request"; + res.status(status).json({ + error, + message: usageValidationMessage( + result.error, + typeof body.serviceId === "string" ? body.serviceId : undefined + ), requestId, }); return; } + const { agent, serviceId, requests } = result; const key = usageKey(agent, serviceId); const prev = usageStore.get(key) ?? 0; const total = Math.min(Number.MAX_SAFE_INTEGER, prev + requests); @@ -85,17 +123,16 @@ export function createUsageRouter(): Router { } const results: BulkUsageResult[] = []; for (let i = 0; i < items.length; i++) { - const { agent, serviceId, requests } = items[i] ?? {}; - if ( - typeof agent !== "string" || - typeof serviceId !== "string" || - typeof requests !== "number" || - !Number.isInteger(requests) || - requests <= 0 - ) { - results.push({ index: i, ok: false, error: "invalid_item" }); + const result = validateUsageItem((items[i] ?? {}) as UsageInput); + if (!result.ok) { + results.push({ + index: i, + ok: false, + error: result.error === "service_disabled" ? "service_disabled" : "invalid_item", + }); continue; } + const { agent, serviceId, requests } = result; const key = usageKey(agent, serviceId); const total = Math.min( Number.MAX_SAFE_INTEGER, diff --git a/src/usage-bulk.test.ts b/src/usage-bulk.test.ts new file mode 100644 index 0000000..7330770 --- /dev/null +++ b/src/usage-bulk.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { + apiKeyStore, + pauseState, + rateBuckets, + servicesDisabled, + servicesMetadata, + servicesStore, + usageKey, + usageStore, + webhookStore, +} from "./store/state.js"; + +function resetState(): void { + pauseState.paused = false; + apiKeyStore.clear(); + rateBuckets.clear(); + servicesDisabled.clear(); + servicesMetadata.clear(); + servicesStore.clear(); + usageStore.clear(); + webhookStore.clear(); +} + +beforeEach(resetState); + +void describe("bulk usage validation parity", () => { + void it("keeps valid rows while rejecting disabled and malformed rows per-index", async () => { + const app = createApp(); + servicesDisabled.add("svc-disabled"); + + const res = await request(app) + .post("/api/v1/usage/bulk") + .send({ + items: [ + { agent: "agent-valid", serviceId: "svc-valid", requests: 2 }, + { agent: "agent-disabled", serviceId: "svc-disabled", requests: 5 }, + { agent: "", serviceId: "svc-valid", requests: 1 }, + { + agent: "a".repeat(257), + serviceId: "svc-valid", + requests: 1, + }, + { + agent: "agent-too-long-service", + serviceId: "s".repeat(129), + requests: 1, + }, + ], + }); + + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(res.body.results, [ + { index: 0, ok: true, total: 2 }, + { index: 1, ok: false, error: "service_disabled" }, + { index: 2, ok: false, error: "invalid_item" }, + { index: 3, ok: false, error: "invalid_item" }, + { index: 4, ok: false, error: "invalid_item" }, + ]); + assert.strictEqual(usageStore.get(usageKey("agent-valid", "svc-valid")), 2); + assert.strictEqual( + usageStore.has(usageKey("agent-disabled", "svc-disabled")), + false + ); + }); + + void it("rejects all malformed rows without writing usage", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/usage/bulk") + .send({ + items: [ + { agent: "", serviceId: "svc", requests: 1 }, + { agent: "agent", serviceId: "", requests: 1 }, + { agent: "agent", serviceId: "svc", requests: 0 }, + { agent: "agent", serviceId: "svc", requests: -1 }, + { agent: "agent", serviceId: "svc", requests: 1.5 }, + { agent: 42, serviceId: "svc", requests: 1 }, + ], + }); + + assert.strictEqual(res.status, 201); + assert.deepStrictEqual( + res.body.results.map((item: { ok: boolean; error: string }) => ({ + ok: item.ok, + error: item.error, + })), + [ + { ok: false, error: "invalid_item" }, + { ok: false, error: "invalid_item" }, + { ok: false, error: "invalid_item" }, + { ok: false, error: "invalid_item" }, + { ok: false, error: "invalid_item" }, + { ok: false, error: "invalid_item" }, + ] + ); + assert.strictEqual(usageStore.size, 0); + }); + + void it("accumulates valid rows in order using the shared rules", async () => { + const app = createApp(); + + const res = await request(app) + .post("/api/v1/usage/bulk") + .send({ + items: [ + { agent: "agent-bulk", serviceId: "svc-bulk", requests: 2 }, + { agent: "agent-bulk", serviceId: "svc-bulk", requests: 3 }, + { agent: "agent-other", serviceId: "svc-bulk", requests: 4 }, + ], + }); + + assert.strictEqual(res.status, 201); + assert.deepStrictEqual(res.body.results, [ + { index: 0, ok: true, total: 2 }, + { index: 1, ok: true, total: 5 }, + { index: 2, ok: true, total: 4 }, + ]); + + const readback = await request(app).get("/api/v1/usage/agent-bulk/svc-bulk"); + assert.strictEqual(readback.status, 200); + assert.strictEqual(readback.body.total, 5); + }); +});