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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ API key for the metering flow until API-key enforcement lands. Add your own
`X-Request-Id` header when you want to correlate client logs with backend
responses. The backend echoes the value on success and structured errors.

Write endpoints use shared request-body schemas before route handlers run. The
same schema registry backs the OpenAPI request-body components, rejects unknown
fields, and preserves the existing `400 invalid_request` response shape with a
client `message` and `requestId`.

Set a shell variable for the local base URL:

```bash
Expand Down
23 changes: 23 additions & 0 deletions src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NextFunction, Request, Response } from "express";
import type { BodySchema } from "../schemas/requestBodies.js";
import { getRequestId } from "../types.js";

/**
* Validates JSON request bodies before a route handler sees them.
*/
export function validateBody(schema: BodySchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const parsed = schema.parse(req.body);
if (!parsed.ok) {
res.status(400).json({
error: "invalid_request",
message: parsed.message,
requestId: getRequestId(req),
});
return;
}

req.body = parsed.value;
next();
};
}
25 changes: 11 additions & 14 deletions src/routes/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import { validateBody } from "../middleware/validate.js";
import { requestBodySchemas } from "../schemas/requestBodies.js";
import { apiKeyStore } from "../store/state.js";
import { getRequestId } from "../types.js";

Expand Down Expand Up @@ -39,21 +41,16 @@ export function createApiKeysRouter(): Router {
res.json({ items });
});

router.post("/api/v1/api-keys", (req: Request, res: Response) => {
const { label } = req.body ?? {};
const requestId = getRequestId(req);
if (typeof label !== "string" || label.length === 0 || label.length > 64) {
res.status(400).json({
error: "invalid_request",
message: "label must be a non-empty string up to 64 chars",
requestId,
});
return;
router.post(
"/api/v1/api-keys",
validateBody(requestBodySchemas.apiKeyCreate),
(req: Request, res: Response) => {
const { label } = req.body ?? {};
const key = `apk_${randomUUID().replace(/-/g, "")}`;
apiKeyStore.set(key, { label, createdAt: Date.now() });
res.status(201).json({ key, label });
}
const key = `apk_${randomUUID().replace(/-/g, "")}`;
apiKeyStore.set(key, { label, createdAt: Date.now() });
res.status(201).json({ key, label });
});
);

return router;
}
30 changes: 13 additions & 17 deletions src/routes/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from "express";
import { validateBody } from "../middleware/validate.js";
import { requestBodySchemas } from "../schemas/requestBodies.js";
import { config } from "../store/state.js";
import { getRequestId } from "../types.js";

const allowedConfigKeys = [
"rateLimitPerWindow",
Expand All @@ -18,25 +19,20 @@ export function createConfigRouter(): Router {
res.json({ config });
});

router.patch("/api/v1/config", (req: Request, res: Response) => {
const requestId = getRequestId(req);
const updates = req.body ?? {};
for (const k of allowedConfigKeys) {
if (k in updates) {
const v = updates[k];
if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
res.status(400).json({
error: "invalid_request",
message: `${k} must be a positive integer`,
requestId,
});
return;
router.patch(
"/api/v1/config",
validateBody(requestBodySchemas.configPatch),
(req: Request, res: Response) => {
const updates = req.body ?? {};
for (const k of allowedConfigKeys) {
if (k in updates) {
const v = updates[k];
config[k] = v;
}
config[k] = v;
}
res.json({ config });
}
res.json({ config });
});
);

return router;
}
80 changes: 71 additions & 9 deletions src/routes/meta.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Router, type Response } from "express";
import {
jsonRequestBodyRef,
openApiRequestBodyComponents,
} from "../schemas/requestBodies.js";
import { pauseState } from "../store/state.js";

/**
Expand Down Expand Up @@ -65,43 +69,101 @@ export function createMetaRouter(): Router {
"/api/v1/events": { get: { summary: "Audit log (?since=&limit=)" } },
"/api/v1/config": {
get: { summary: "Read runtime config" },
patch: { summary: "Update runtime config" },
patch: {
summary: "Update runtime config",
requestBody: jsonRequestBodyRef("configPatch"),
},
},
"/api/v1/services": {
get: { summary: "List services" },
post: { summary: "Register a service" },
post: {
summary: "Register a service",
requestBody: jsonRequestBodyRef("serviceCreate"),
},
},
"/api/v1/services/bulk": {
post: {
summary: "Register services in bulk",
requestBody: jsonRequestBodyRef("bulkServices"),
},
},
"/api/v1/services/{serviceId}": {
get: { summary: "Fetch one service" },
delete: { summary: "Unregister service" },
},
"/api/v1/services/{serviceId}/price": {
patch: { summary: "Update price only" },
patch: {
summary: "Update price only",
requestBody: jsonRequestBodyRef("servicePricePatch"),
},
},
"/api/v1/services/{serviceId}/metadata": {
get: { summary: "Read service metadata" },
put: {
summary: "Set service metadata",
requestBody: jsonRequestBodyRef("serviceMetadataPut"),
},
},
"/api/v1/services/{serviceId}/disabled": {
patch: {
summary: "Enable or disable a service",
requestBody: jsonRequestBodyRef("serviceDisabledPatch"),
},
},
"/api/v1/services/{serviceId}/agents": {
get: { summary: "List agents on a service" },
},
"/api/v1/agents/{agent}/usage": { get: { summary: "Per-service usage" } },
"/api/v1/agents/{agent}/total": { get: { summary: "Lifetime total" } },
"/api/v1/usage": { post: { summary: "Record usage" } },
"/api/v1/usage/bulk": { post: { summary: "Batched record" } },
"/api/v1/usage": {
post: {
summary: "Record usage",
requestBody: jsonRequestBodyRef("usageRecord"),
},
},
"/api/v1/usage/bulk": {
post: {
summary: "Batched record",
requestBody: jsonRequestBodyRef("bulkUsage"),
},
},
"/api/v1/usage/{agent}/{serviceId}": { get: { summary: "Read accumulator" } },
"/api/v1/billing/{agent}/{serviceId}": { get: { summary: "Quote bill" } },
"/api/v1/settle": { post: { summary: "Drain & quote bill" } },
"/api/v1/settle": {
post: {
summary: "Drain & quote bill",
requestBody: jsonRequestBodyRef("settle"),
},
},
"/api/v1/api-keys": {
get: { summary: "List api keys" },
post: { summary: "Create api key" },
post: {
summary: "Create api key",
requestBody: jsonRequestBodyRef("apiKeyCreate"),
},
},
"/api/v1/api-keys/{prefix}": { delete: { summary: "Revoke by prefix" } },
"/api/v1/webhooks": {
get: { summary: "List webhooks" },
post: { summary: "Register webhook" },
post: {
summary: "Register webhook",
requestBody: jsonRequestBodyRef("webhookCreate"),
},
},
"/api/v1/webhooks/{id}": {
delete: { summary: "Unregister webhook" },
patch: {
summary: "Update webhook",
requestBody: jsonRequestBodyRef("webhookPatch"),
},
},
"/api/v1/webhooks/{id}": { delete: { summary: "Unregister webhook" } },
"/api/v1/admin/pause": { post: { summary: "Pause writes" } },
"/api/v1/admin/unpause": { post: { summary: "Resume" } },
"/api/v1/admin/status": { get: { summary: "Read pause flag" } },
},
components: {
schemas: openApiRequestBodyComponents,
},
});
});

Expand Down
Loading