diff --git a/README.md b/README.md index 337ee4f..806ffd3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ agentpay-backend/ stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why `POST /api/v1/settle` drains backend counters without moving funds. +## Query Parameters + +Numeric query parameters are parsed defensively. Malformed, `NaN`, infinite, or +missing numeric values fall back to the endpoint default, while values below or +above the supported range are clamped: + +| Endpoint family | Parameter | Default | Range | +| ------------------------------------- | --------- | ------- | ---------------- | +| `GET /api/v1/agents` | `limit` | `200` | `1` to `1000` | +| `GET /api/v1/services` | `limit` | `200` | `1` to `1000` | +| `GET /api/v1/services/:id/agents/top` | `limit` | `10` | `1` to `100` | +| `GET /api/v1/events` | `limit` | `100` | `1` to event cap | +| `GET /api/v1/events` | `since` | `0` | `0` or higher | + +This keeps bad input such as `?limit=abc` or `?since=abc` from propagating +`NaN` into slices or timestamp filters. + ## Quickstart Start a local backend on `http://localhost:3001` with the checked-in diff --git a/src/query-params.test.ts b/src/query-params.test.ts new file mode 100644 index 0000000..294041c --- /dev/null +++ b/src/query-params.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import request from "supertest"; +import { app } from "./index.js"; +import { parseIntParam } from "./queryParams.js"; + +void describe("numeric query parameter parsing", () => { + void it("falls back and clamps numeric query parameters", () => { + const options = { defaultValue: 10, min: 1, max: 100 }; + + assert.strictEqual(parseIntParam(undefined, options), 10); + assert.strictEqual(parseIntParam("abc", options), 10); + assert.strictEqual(parseIntParam("NaN", options), 10); + assert.strictEqual(parseIntParam("Infinity", options), 10); + assert.strictEqual(parseIntParam("0", options), 1); + assert.strictEqual(parseIntParam("-5", options), 1); + assert.strictEqual(parseIntParam("250", options), 100); + assert.strictEqual(parseIntParam("3.7", options), 3); + }); + + void it("falls back to the default limit on agent lists", async () => { + await request(app) + .post("/api/v1/usage") + .send({ agent: "qp-agent-a", serviceId: "qp-service", requests: 1 }); + await request(app) + .post("/api/v1/usage") + .send({ agent: "qp-agent-b", serviceId: "qp-service", requests: 1 }); + + const fallback = await request(app).get("/api/v1/agents?limit=abc"); + const clamped = await request(app).get("/api/v1/agents?limit=0"); + + assert.strictEqual(fallback.status, 200); + assert.ok(fallback.body.agents.includes("qp-agent-a")); + assert.ok(fallback.body.agents.includes("qp-agent-b")); + assert.strictEqual(clamped.body.agents.length, 1); + }); + + void it("falls back to the default limit on top service-agent lists", async () => { + await request(app) + .post("/api/v1/usage") + .send({ agent: "qp-top-a", serviceId: "qp-top-service", requests: 5 }); + await request(app) + .post("/api/v1/usage") + .send({ agent: "qp-top-b", serviceId: "qp-top-service", requests: 3 }); + + const fallback = await request(app).get( + "/api/v1/services/qp-top-service/agents/top?limit=abc" + ); + + assert.strictEqual(fallback.status, 200); + assert.deepStrictEqual( + fallback.body.items.map((item: { agent: string }) => item.agent), + ["qp-top-a", "qp-top-b"] + ); + }); + + void it("falls back to since=0 instead of hiding every event", async () => { + await request(app) + .post("/api/v1/usage") + .send({ agent: "qp-event-agent", serviceId: "qp-event-service", requests: 1 }); + + const res = await request(app).get("/api/v1/events?since=abc&limit=100"); + + assert.strictEqual(res.status, 200); + assert.ok( + res.body.items.some( + (item: { type: string; payload?: { agent?: string } }) => + item.type === "usage.recorded" && item.payload?.agent === "qp-event-agent" + ) + ); + }); +}); diff --git a/src/queryParams.ts b/src/queryParams.ts new file mode 100644 index 0000000..8dbee73 --- /dev/null +++ b/src/queryParams.ts @@ -0,0 +1,23 @@ +type IntParamOptions = { + defaultValue: number; + min: number; + max: number; +}; + +/** + * Parses integer query params with fallback and bounded output. + */ +export function parseIntParam( + value: unknown, + { defaultValue, min, max }: IntParamOptions +): number { + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw !== "string" && typeof raw !== "number") return defaultValue; + if (raw === "") return defaultValue; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return defaultValue; + + const integer = Math.trunc(parsed); + return Math.min(max, Math.max(min, integer)); +} diff --git a/src/routes/events.ts b/src/routes/events.ts index b173993..0d467b3 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import { EVENT_LOG_CAP, eventLog } from "../events.js"; +import { parseIntParam } from "../queryParams.js"; /** * Builds read-only audit-event routes. @@ -14,12 +15,17 @@ export function createEventsRouter(): Router { }); router.get("/api/v1/events", (req: Request, res: Response) => { - const since = Number((req.query.since as string) ?? 0); + const since = parseIntParam(req.query.since, { + defaultValue: 0, + min: 0, + max: Number.MAX_SAFE_INTEGER, + }); const type = typeof req.query.type === "string" ? req.query.type : undefined; - const limit = Math.min( - EVENT_LOG_CAP, - Math.max(1, Number((req.query.limit as string) ?? 100)) - ); + const limit = parseIntParam(req.query.limit, { + defaultValue: 100, + min: 1, + max: EVENT_LOG_CAP, + }); const items = eventLog .filter((e) => e.ts >= since && (type === undefined || e.type === type)) .slice(-limit); diff --git a/src/routes/services.ts b/src/routes/services.ts index c606e5c..af4f945 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; +import { parseIntParam } from "../queryParams.js"; import { servicesDisabled, servicesMetadata, @@ -127,10 +128,11 @@ export function createServicesRouter(): Router { "/api/v1/services/:serviceId/agents/top", (req: Request, res: Response) => { const { serviceId } = req.params; - const limit = Math.min( - 100, - Math.max(1, Number((req.query.limit as string) ?? 10)) - ); + const limit = parseIntParam(req.query.limit, { + defaultValue: 10, + min: 1, + max: 100, + }); const suffix = `::${serviceId}`; const items: { agent: string; total: number }[] = []; for (const [key, total] of usageStore.entries()) { @@ -292,10 +294,11 @@ export function createServicesRouter(): Router { router.get("/api/v1/services", (req: Request, res: Response) => { const prefix = typeof req.query.prefix === "string" ? req.query.prefix : ""; const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : ""; - const limit = Math.min( - 1000, - Math.max(1, Number((req.query.limit as string) ?? 200)) - ); + const limit = parseIntParam(req.query.limit, { + defaultValue: 200, + min: 1, + max: 1000, + }); const services: ServiceReadShape[] = []; for (const [serviceId, meta] of servicesStore.entries()) { if (prefix && !serviceId.startsWith(prefix)) continue; diff --git a/src/routes/usage.ts b/src/routes/usage.ts index cd28a50..15459e0 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import { recordEvent } from "../events.js"; +import { parseIntParam } from "../queryParams.js"; import { servicesDisabled, servicesStore, @@ -212,10 +213,11 @@ export function createUsageRouter(): Router { }); router.get("/api/v1/agents", (req: Request, res: Response) => { - const limit = Math.min( - 1000, - Math.max(1, Number((req.query.limit as string) ?? 200)) - ); + const limit = parseIntParam(req.query.limit, { + defaultValue: 200, + min: 1, + max: 1000, + }); const seen = new Set(); for (const key of usageStore.keys()) seen.add(key.split("::")[0]); const agents = Array.from(seen).slice(0, limit);