Skip to content
Closed
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
123 changes: 80 additions & 43 deletions src/routes/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,54 +15,92 @@ 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.
*/
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);
Expand All @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions src/usage-bulk.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading