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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions src/query-params.test.ts
Original file line number Diff line number Diff line change
@@ -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"
)
);
});
});
23 changes: 23 additions & 0 deletions src/queryParams.ts
Original file line number Diff line number Diff line change
@@ -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));
}
16 changes: 11 additions & 5 deletions src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
Expand Down
19 changes: 11 additions & 8 deletions src/routes/services.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions src/routes/usage.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string>();
for (const key of usageStore.keys()) seen.add(key.split("::")[0]);
const agents = Array.from(seen).slice(0, limit);
Expand Down