Add request validation and OpenAPI generation

This commit is contained in:
Alexa Amundson
2025-11-23 22:57:04 -06:00
parent ad4ca85711
commit c7e2b4cf26
11 changed files with 1378 additions and 48 deletions

View File

@@ -42,6 +42,8 @@ Environment is centralized in `src/config.ts` via `getConfig()`.
- The API is a thin adapter: it shapes responses, validates inputs, and delegates business logic to `blackroad-os-operator` and `blackroad-os-core` when available. - The API is a thin adapter: it shapes responses, validates inputs, and delegates business logic to `blackroad-os-operator` and `blackroad-os-core` when available.
- RoadChain and some finance data are mocked for now; TODO markers indicate where to swap in real upstream calls. - RoadChain and some finance data are mocked for now; TODO markers indicate where to swap in real upstream calls.
- Responses always follow the `{ ok: boolean; data?; error? }` envelope to keep Prism and other clients stable. - Responses always follow the `{ ok: boolean; data?; error? }` envelope to keep Prism and other clients stable.
- Requests are validated with Zod via `validateRequest`; invalid params return `{ ok: false, error: { code: "INVALID_REQUEST" } }`.
- Run `npm run generate:openapi` to produce `docs/openapi.generated.json` from the runtime schemas.
## Testing ## Testing
```bash ```bash

838
docs/openapi.generated.json Normal file
View File

@@ -0,0 +1,838 @@
{
"openapi": "3.0.3",
"info": {
"title": "BlackRoad OS API",
"version": "0.2.0",
"description": "Typed HTTP surface for BlackRoad OS"
},
"servers": [
{
"url": "http://localhost:4000",
"description": "Local development"
},
{
"url": "https://api.blackroad.systems",
"description": "Production"
}
],
"components": {
"schemas": {
"ApiError": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
false
]
},
"error": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"details": {
"nullable": true
}
},
"required": [
"code",
"message"
]
}
},
"required": [
"ok",
"error"
]
},
"Agent": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"role": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"idle",
"running",
"error",
"offline"
]
},
"lastHeartbeat": {
"type": "string"
},
"version": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"id",
"name",
"role",
"status",
"lastHeartbeat"
]
},
"ServiceHealth": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"healthy",
"degraded",
"down"
]
},
"latencyMs": {
"type": "number"
},
"lastChecked": {
"type": "string"
}
},
"required": [
"id",
"name",
"status",
"lastChecked"
]
},
"HealthResponse": {
"type": "object",
"properties": {
"overallStatus": {
"type": "string",
"enum": [
"healthy",
"degraded",
"down"
]
},
"services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"healthy",
"degraded",
"down"
]
},
"latencyMs": {
"type": "number"
},
"lastChecked": {
"type": "string"
}
},
"required": [
"id",
"name",
"status",
"lastChecked"
]
}
}
},
"required": [
"overallStatus",
"services"
]
},
"SystemOverview": {
"type": "object",
"properties": {
"overallStatus": {
"type": "string",
"enum": [
"healthy",
"degraded",
"down"
]
},
"services": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"healthy",
"degraded",
"down"
]
},
"latencyMs": {
"type": "number"
},
"lastChecked": {
"type": "string"
}
},
"required": [
"id",
"name",
"status",
"lastChecked"
]
}
},
"jobsProcessedLast24h": {
"type": "number"
},
"errorsLast24h": {
"type": "number"
},
"notes": {
"type": "string"
}
},
"required": [
"overallStatus",
"services"
]
},
"FinanceSnapshot": {
"type": "object",
"properties": {
"timestamp": {
"type": "string"
},
"monthlyInfraCostUsd": {
"type": "number"
},
"monthlyRevenueUsd": {
"type": "number"
},
"estimatedSavingsUsd": {
"type": "number"
},
"walletBalanceUsd": {
"type": "number"
},
"notes": {
"type": "string"
}
},
"required": [
"timestamp"
]
},
"FinanceSummary": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"cashBalance": {
"type": "number"
},
"monthlyBurnRate": {
"type": "number"
},
"runwayMonths": {
"type": "number"
},
"mrr": {
"type": "number"
},
"arr": {
"type": "number"
},
"generatedAt": {
"type": "string"
}
},
"required": [
"currency",
"cashBalance",
"monthlyBurnRate",
"runwayMonths",
"generatedAt"
]
},
"CashForecast": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"buckets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"startDate": {
"type": "string"
},
"endDate": {
"type": "string"
},
"netChange": {
"type": "number"
},
"endingBalance": {
"type": "number"
}
},
"required": [
"startDate",
"endDate",
"netChange",
"endingBalance"
]
}
},
"generatedAt": {
"type": "string"
}
},
"required": [
"currency",
"buckets",
"generatedAt"
]
},
"EventRecord": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"timestamp": {
"type": "string"
},
"source": {
"type": "string"
},
"type": {
"type": "string"
},
"summary": {
"type": "string"
},
"psShaInfinity": {
"type": "string"
},
"severity": {
"type": "string",
"enum": [
"info",
"warning",
"error"
]
}
},
"required": [
"id",
"timestamp",
"source",
"type",
"summary"
]
},
"RoadChainBlock": {
"type": "object",
"properties": {
"height": {
"type": "number"
},
"hash": {
"type": "string"
},
"prevHash": {
"type": "string"
},
"timestamp": {
"type": "string"
},
"eventIds": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"height",
"hash",
"prevHash",
"timestamp",
"eventIds"
]
}
},
"parameters": {}
},
"paths": {
"/api/v1/health": {
"get": {
"description": "API and dependency health",
"tags": [
"health"
],
"responses": {
"200": {
"description": "Aggregated service health",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/HealthResponse"
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
},
"/api/v1/system/overview": {
"get": {
"description": "System overview",
"tags": [
"system"
],
"responses": {
"200": {
"description": "Overall system overview",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/SystemOverview"
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
},
"/api/v1/agents": {
"get": {
"description": "List agents with optional filters",
"tags": [
"agents"
],
"parameters": [
{
"schema": {
"type": "string",
"enum": [
"idle",
"running",
"error",
"offline"
]
},
"required": false,
"name": "status",
"in": "query"
},
{
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"required": false,
"name": "q",
"in": "query"
}
],
"responses": {
"200": {
"description": "Agents matching filters",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Agent"
}
}
},
"required": [
"ok",
"data"
]
}
}
}
},
"400": {
"description": "Invalid filters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/agents/{id}": {
"get": {
"description": "Fetch a single agent by ID",
"tags": [
"agents"
],
"parameters": [
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "id",
"in": "path"
}
],
"responses": {
"200": {
"description": "Agent found",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/Agent"
}
},
"required": [
"ok",
"data"
]
}
}
}
},
"404": {
"description": "Agent not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/finance/snapshot": {
"get": {
"description": "Finance snapshot",
"tags": [
"finance"
],
"responses": {
"200": {
"description": "Finance snapshot",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/FinanceSnapshot"
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
},
"/api/v1/finance/summary": {
"get": {
"description": "Finance summary",
"tags": [
"finance"
],
"responses": {
"200": {
"description": "Finance summary",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/FinanceSummary"
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
},
"/api/v1/finance/cash-forecast": {
"get": {
"description": "Cash forecast",
"tags": [
"finance"
],
"responses": {
"200": {
"description": "Cash forecast",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"$ref": "#/components/schemas/CashForecast"
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
},
"/api/v1/events": {
"get": {
"description": "Event feed with filters",
"tags": [
"events"
],
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
},
"required": false,
"name": "limit",
"in": "query"
},
{
"schema": {
"type": "string",
"enum": [
"info",
"warning",
"error"
]
},
"required": false,
"name": "severity",
"in": "query"
},
{
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"required": false,
"name": "source",
"in": "query"
}
],
"responses": {
"200": {
"description": "Filtered events",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventRecord"
}
}
},
"required": [
"ok",
"data"
]
}
}
}
},
"400": {
"description": "Invalid filters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/roadchain/blocks": {
"get": {
"description": "RoadChain block headers",
"tags": [
"roadchain"
],
"responses": {
"200": {
"description": "Recent blocks",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"enum": [
true
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RoadChainBlock"
}
}
},
"required": [
"ok",
"data"
]
}
}
}
}
}
}
}
}
}

47
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "blackroad-os-public-api", "name": "blackroad-os-public-api",
"version": "0.2.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.1.0",
"axios": "^1.7.4", "axios": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2" "express": "^4.19.2",
"zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@@ -22,10 +24,23 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"supertest": "^6.3.4", "supertest": "^6.3.4",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
}, },
"node_modules/@asteasolutions/zod-to-openapi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.1.0.tgz",
"integrity": "sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g==",
"license": "MIT",
"dependencies": {
"openapi3-ts": "^4.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -4172,6 +4187,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openapi3-ts": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz",
"integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==",
"license": "MIT",
"dependencies": {
"yaml": "^2.8.0"
}
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5506,6 +5530,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -5557,6 +5593,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View File

@@ -7,13 +7,16 @@
"dev": "ts-node-dev --respawn --transpile-only src/index.ts", "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "NODE_ENV=test jest" "test": "NODE_ENV=test jest",
"generate:openapi": "ts-node scripts/generate-openapi.ts"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.1.0",
"axios": "^1.7.4", "axios": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2" "express": "^4.19.2",
"zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@@ -24,6 +27,7 @@
"jest": "^29.7.0", "jest": "^29.7.0",
"supertest": "^6.3.4", "supertest": "^6.3.4",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }

View File

@@ -0,0 +1,9 @@
import { writeFileSync } from "fs";
import { resolve } from "path";
import { buildOpenAPIDocument } from "../src/openapi";
const outputPath = resolve(__dirname, "../docs/openapi.generated.json");
const document = buildOpenAPIDocument();
writeFileSync(outputPath, JSON.stringify(document, null, 2));
console.log(`OpenAPI spec written to ${outputPath}`);

View File

@@ -0,0 +1,51 @@
import { NextFunction, Request, RequestHandler, Response } from "express";
import { ZodError, ZodIssue, ZodTypeAny } from "zod";
import { ApiRouteError } from "./errorHandler";
interface RequestSchemas {
body?: ZodTypeAny;
query?: ZodTypeAny;
params?: ZodTypeAny;
}
function formatZodError(error: ZodError) {
return error.issues.map((issue: ZodIssue) => ({
path: issue.path.join("."),
message: issue.message,
code: issue.code,
}));
}
export function validateRequest(schemas: RequestSchemas): RequestHandler {
return (req: Request, _res: Response, next: NextFunction) => {
try {
if (schemas.query) {
const parsedQuery = schemas.query.parse(req.query);
req.query = parsedQuery as any;
}
if (schemas.params) {
const parsedParams = schemas.params.parse(req.params);
req.params = parsedParams as any;
}
if (schemas.body) {
const parsedBody = schemas.body.parse(req.body);
req.body = parsedBody;
}
next();
} catch (err) {
const apiError: ApiRouteError = new Error("Invalid request");
apiError.statusCode = 400;
apiError.code = "INVALID_REQUEST";
if (err instanceof ZodError) {
apiError.details = formatZodError(err);
apiError.message = "Request validation failed";
}
next(apiError);
}
};
}

193
src/openapi/index.ts Normal file
View File

@@ -0,0 +1,193 @@
import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import {
ApiErrorSchema,
agentIdParamsSchema,
agentListQuerySchema,
agentSchema,
apiSuccessSchema,
cashForecastSchema,
eventRecordSchema,
eventsQuerySchema,
healthResponseSchema,
financeSnapshotSchema,
financeSummarySchema,
roadChainBlockSchema,
serviceHealthSchema,
systemOverviewSchema,
} from "../validation/schemas";
function jsonResponse(schema: any) {
return {
content: {
"application/json": {
schema,
},
},
} as const;
}
export function buildOpenAPIDocument() {
const registry = new OpenAPIRegistry();
const ApiError = registry.register("ApiError", ApiErrorSchema);
const Agent = registry.register("Agent", agentSchema);
const ServiceHealth = registry.register("ServiceHealth", serviceHealthSchema);
const HealthResponse = registry.register("HealthResponse", healthResponseSchema);
const SystemOverview = registry.register("SystemOverview", systemOverviewSchema);
const FinanceSnapshot = registry.register("FinanceSnapshot", financeSnapshotSchema);
const FinanceSummary = registry.register("FinanceSummary", financeSummarySchema);
const CashForecast = registry.register("CashForecast", cashForecastSchema);
const EventRecord = registry.register("EventRecord", eventRecordSchema);
const RoadChainBlock = registry.register("RoadChainBlock", roadChainBlockSchema);
registry.registerPath({
method: "get",
path: "/api/v1/health",
description: "API and dependency health",
tags: ["health"],
responses: {
200: {
description: "Aggregated service health",
...jsonResponse(apiSuccessSchema(HealthResponse)),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/system/overview",
description: "System overview",
tags: ["system"],
responses: {
200: {
description: "Overall system overview",
...jsonResponse(apiSuccessSchema(SystemOverview)),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/agents",
description: "List agents with optional filters",
tags: ["agents"],
request: {
query: agentListQuerySchema,
},
responses: {
200: {
description: "Agents matching filters",
...jsonResponse(apiSuccessSchema(Agent.array())),
},
400: {
description: "Invalid filters",
...jsonResponse(ApiError),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/agents/{id}",
description: "Fetch a single agent by ID",
tags: ["agents"],
request: {
params: agentIdParamsSchema,
},
responses: {
200: {
description: "Agent found",
...jsonResponse(apiSuccessSchema(Agent)),
},
404: {
description: "Agent not found",
...jsonResponse(ApiError),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/finance/snapshot",
description: "Finance snapshot",
tags: ["finance"],
responses: {
200: {
description: "Finance snapshot",
...jsonResponse(apiSuccessSchema(FinanceSnapshot)),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/finance/summary",
description: "Finance summary",
tags: ["finance"],
responses: {
200: {
description: "Finance summary",
...jsonResponse(apiSuccessSchema(FinanceSummary)),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/finance/cash-forecast",
description: "Cash forecast",
tags: ["finance"],
responses: {
200: {
description: "Cash forecast",
...jsonResponse(apiSuccessSchema(CashForecast)),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/events",
description: "Event feed with filters",
tags: ["events"],
request: { query: eventsQuerySchema },
responses: {
200: {
description: "Filtered events",
...jsonResponse(apiSuccessSchema(EventRecord.array())),
},
400: {
description: "Invalid filters",
...jsonResponse(ApiError),
},
},
});
registry.registerPath({
method: "get",
path: "/api/v1/roadchain/blocks",
description: "RoadChain block headers",
tags: ["roadchain"],
responses: {
200: {
description: "Recent blocks",
...jsonResponse(apiSuccessSchema(RoadChainBlock.array())),
},
},
});
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.3",
info: {
title: "BlackRoad OS API",
version: "0.2.0",
description: "Typed HTTP surface for BlackRoad OS",
},
servers: [
{ url: "http://localhost:4000", description: "Local development" },
{ url: "https://api.blackroad.systems", description: "Production" },
],
});
}

View File

@@ -1,48 +1,64 @@
import { Router } from "express"; import { Router } from "express";
import { fetchAgentById, fetchAgents } from "../clients/operatorClient"; import { fetchAgentById, fetchAgents } from "../clients/operatorClient";
import { ApiRouteError } from "../middleware/errorHandler"; import { ApiRouteError } from "../middleware/errorHandler";
import { validateRequest } from "../middleware/validateRequest";
import { Agent, ApiResponse } from "../types/api"; import { Agent, ApiResponse } from "../types/api";
import {
AgentIdParams,
AgentListQuery,
agentIdParamsSchema,
agentListQuerySchema,
} from "../validation/schemas";
export function createAgentsRouter() { export function createAgentsRouter() {
const router = Router(); const router = Router();
router.get("/", async (req, res, next) => { router.get(
try { "/",
const { status, q } = req.query; validateRequest({ query: agentListQuerySchema }),
const agents = await fetchAgents(); async (req, res, next) => {
const filtered = agents.filter((agent) => { try {
const statusMatch = status ? agent.status === status : true; const { status, q } = req.query as AgentListQuery;
const query = (q as string | undefined)?.toLowerCase(); const agents = await fetchAgents();
const queryMatch = query const filtered = agents.filter((agent) => {
? agent.name.toLowerCase().includes(query) || const statusMatch = status ? agent.status === status : true;
(agent.tags || []).some((tag) => tag.toLowerCase().includes(query)) const query = q?.toLowerCase();
: true; const queryMatch = query
return statusMatch && queryMatch; ? agent.name.toLowerCase().includes(query) ||
}); (agent.tags || []).some((tag) => tag.toLowerCase().includes(query))
: true;
return statusMatch && queryMatch;
});
const response: ApiResponse<Agent[]> = { ok: true, data: filtered }; const response: ApiResponse<Agent[]> = { ok: true, data: filtered };
res.json(response); res.json(response);
} catch (err) { } catch (err) {
next(err); next(err);
}
});
router.get("/:id", async (req, res, next) => {
try {
const agent = await fetchAgentById(req.params.id);
if (!agent) {
const error: ApiRouteError = new Error("Agent not found");
error.statusCode = 404;
error.code = "AGENT_NOT_FOUND";
throw error;
} }
const response: ApiResponse<Agent> = { ok: true, data: agent };
res.json(response);
} catch (err) {
next(err);
} }
}); );
router.get(
"/:id",
validateRequest({ params: agentIdParamsSchema }),
async (req, res, next) => {
try {
const { id } = req.params as AgentIdParams;
const agent = await fetchAgentById(id);
if (!agent) {
const error: ApiRouteError = new Error("Agent not found");
error.statusCode = 404;
error.code = "AGENT_NOT_FOUND";
throw error;
}
const response: ApiResponse<Agent> = { ok: true, data: agent };
res.json(response);
} catch (err) {
next(err);
}
}
);
return router; return router;
} }

View File

@@ -1,5 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { validateRequest } from "../middleware/validateRequest";
import { ApiResponse, EventRecord } from "../types/api"; import { ApiResponse, EventRecord } from "../types/api";
import { EventsQuery, eventsQuerySchema } from "../validation/schemas";
const MOCK_EVENTS: EventRecord[] = [ const MOCK_EVENTS: EventRecord[] = [
{ {
@@ -32,18 +34,21 @@ const MOCK_EVENTS: EventRecord[] = [
export function createEventsRouter() { export function createEventsRouter() {
const router = Router(); const router = Router();
router.get("/", (req, res) => { router.get(
const { limit = "50", severity, source } = req.query; "/",
const parsedLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); validateRequest({ query: eventsQuerySchema }),
const filtered = MOCK_EVENTS.filter((event) => { (req, res) => {
const severityMatch = severity ? event.severity === severity : true; const { limit, severity, source } = req.query as unknown as EventsQuery;
const sourceMatch = source ? event.source === source : true; const filtered = MOCK_EVENTS.filter((event) => {
return severityMatch && sourceMatch; const severityMatch = severity ? event.severity === severity : true;
}).slice(0, parsedLimit); const sourceMatch = source ? event.source === source : true;
return severityMatch && sourceMatch;
}).slice(0, limit ?? 50);
const response: ApiResponse<EventRecord[]> = { ok: true, data: filtered }; const response: ApiResponse<EventRecord[]> = { ok: true, data: filtered };
res.json(response); res.json(response);
}); }
);
return router; return router;
} }

137
src/validation/schemas.ts Normal file
View File

@@ -0,0 +1,137 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export const ApiErrorSchema = z.object({
ok: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.unknown().optional(),
}),
});
export const apiSuccessSchema = <T extends z.ZodTypeAny>(schema: T) =>
z.object({
ok: z.literal(true),
data: schema,
});
export const serviceHealthSchema = z.object({
id: z.string(),
name: z.string(),
status: z.enum(["healthy", "degraded", "down"]),
latencyMs: z.number().optional(),
lastChecked: z.string(),
});
export const healthResponseSchema = z.object({
overallStatus: z.enum(["healthy", "degraded", "down"]),
services: z.array(serviceHealthSchema),
});
export const systemOverviewSchema = z.object({
overallStatus: z.enum(["healthy", "degraded", "down"]),
services: z.array(serviceHealthSchema),
jobsProcessedLast24h: z.number().optional(),
errorsLast24h: z.number().optional(),
notes: z.string().optional(),
});
export const agentStatusSchema = z.enum(["idle", "running", "error", "offline"]);
export const agentSchema = z.object({
id: z.string(),
name: z.string(),
role: z.string(),
status: agentStatusSchema,
lastHeartbeat: z.string(),
version: z.string().optional(),
tags: z.array(z.string()).optional(),
});
export const agentListQuerySchema = z
.object({
status: agentStatusSchema.optional(),
q: z
.string()
.trim()
.min(1, "Query must be at least 1 character")
.max(120, "Query must be 120 characters or fewer")
.optional(),
})
.strict();
export const agentIdParamsSchema = z.object({
id: z.string().trim().min(1, "Agent id cannot be empty"),
});
export const financeSnapshotSchema = z.object({
timestamp: z.string(),
monthlyInfraCostUsd: z.number().optional(),
monthlyRevenueUsd: z.number().optional(),
estimatedSavingsUsd: z.number().optional(),
walletBalanceUsd: z.number().optional(),
notes: z.string().optional(),
});
export const financeSummarySchema = z.object({
currency: z.string(),
cashBalance: z.number(),
monthlyBurnRate: z.number(),
runwayMonths: z.number(),
mrr: z.number().optional(),
arr: z.number().optional(),
generatedAt: z.string(),
});
export const cashForecastBucketSchema = z.object({
startDate: z.string(),
endDate: z.string(),
netChange: z.number(),
endingBalance: z.number(),
});
export const cashForecastSchema = z.object({
currency: z.string(),
buckets: z.array(cashForecastBucketSchema),
generatedAt: z.string(),
});
export const eventSeveritySchema = z.enum(["info", "warning", "error"]);
export const eventRecordSchema = z.object({
id: z.string(),
timestamp: z.string(),
source: z.string(),
type: z.string(),
summary: z.string(),
psShaInfinity: z.string().optional(),
severity: eventSeveritySchema.optional(),
});
export const eventsQuerySchema = z
.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
severity: eventSeveritySchema.optional(),
source: z
.string()
.trim()
.min(1, "Source must be at least 1 character")
.max(64, "Source must be 64 characters or fewer")
.optional(),
})
.strict();
export const roadChainBlockSchema = z.object({
height: z.number(),
hash: z.string(),
prevHash: z.string(),
timestamp: z.string(),
eventIds: z.array(z.string()),
});
export type AgentListQuery = z.infer<typeof agentListQuerySchema>;
export type AgentIdParams = z.infer<typeof agentIdParamsSchema>;
export type EventsQuery = z.infer<typeof eventsQuerySchema>;

30
tests/validation.test.ts Normal file
View File

@@ -0,0 +1,30 @@
import request from "supertest";
import { createApp } from "../src/server";
const app = createApp();
describe("request validation", () => {
it("rejects unknown agent status filters", async () => {
const res = await request(app).get("/api/v1/agents?status=bogus").expect(400);
expect(res.body.ok).toBe(false);
expect(res.body.error.code).toBe("INVALID_REQUEST");
});
it("clamps and defaults events limit", async () => {
const res = await request(app).get("/api/v1/events?limit=1").expect(200);
expect(res.body.ok).toBe(true);
expect(res.body.data.length).toBe(1);
});
it("rejects invalid event severity", async () => {
const res = await request(app)
.get("/api/v1/events?severity=critical")
.expect(400);
expect(res.body.ok).toBe(false);
expect(res.body.error.code).toBe("INVALID_REQUEST");
expect(res.body.error.details?.length).toBeGreaterThan(0);
});
});