Implement v1 API surface with typed routes

This commit is contained in:
Alexa Amundson
2025-11-23 16:25:03 -06:00
parent cc730a156f
commit e0044965c2
23 changed files with 566 additions and 430 deletions

View File

@@ -1,11 +1,17 @@
# BlackRoad OS Public API # BlackRoad OS Public API
Public API gateway for the BlackRoad Operating System. This service exposes health/version endpoints for monitoring and proxies finance data from the Automated Finance Layer in **blackroad-os-operator**. `blackroad-os-api` is the typed HTTP surface for BlackRoad OS. It exposes versioned JSON endpoints that Prism Console and other clients use to query health, agents, finance, events, and RoadChain data. This service participates in the shared **"BlackRoad OS - Master Orchestration"** project alongside Operator, Core, Prism, Web, and Infra.
## Features ## Core Endpoints
- **Health & Version** endpoints for observability. All routes are prefixed with `/api/v1` and return the standard `{ ok, data | error }` envelope.
- **Finance** endpoints for summaries, cash forecasts, and financial statements.
- Structured, testable Express setup with centralized middleware. - `GET /api/v1/health` API + dependency health summary
- `GET /api/v1/system/overview` Aggregated system status and recent metrics
- `GET /api/v1/agents` List agents with optional `status` and `q` filters
- `GET /api/v1/agents/:id` Agent detail
- `GET /api/v1/finance/snapshot` Finance/treasury snapshot
- `GET /api/v1/events` Recent journal-style events with optional filters
- `GET /api/v1/roadchain/blocks` RoadChain block headers (mocked for now)
## Getting Started ## Getting Started
1. Install dependencies: 1. Install dependencies:
@@ -16,42 +22,34 @@ Public API gateway for the BlackRoad Operating System. This service exposes heal
```bash ```bash
npm run dev npm run dev
``` ```
3. Build for production: 3. Build and start production bundle:
```bash ```bash
npm run build npm run build && npm start
```
4. Start the compiled server:
```bash
npm start
``` ```
The server listens on `http://localhost:3001` by default or the configured `PORT`. The server listens on `http://localhost:4000` by default or the configured `PORT`.
## Configuration ## Configuration
Set the following environment variables as needed: Environment is centralized in `src/config.ts` via `getConfig()`.
- `PORT` Port to bind (default `3001`)
- `LOG_LEVEL` Logging verbosity (default `info`)
- `OPERATOR_BASE_URL` Base URL for `blackroad-os-operator`
- `REQUEST_TIMEOUT_MS` Timeout for upstream requests (default `5000`)
## Example Requests - `NODE_ENV` `development` | `test` | `production` (default: `development`)
```bash - `PORT` HTTP port (default: `4000`)
curl http://localhost:3001/health - `OPERATOR_API_BASE_URL` Base URL for `blackroad-os-operator` (required in production, default: `http://localhost:4100`)
curl http://localhost:3001/finance/summary - `ROADCHAIN_BASE_URL` Optional base for a future RoadChain backend
curl http://localhost:3001/finance/statements/2025-Q1 - `LOG_LEVEL` Log verbosity (default: `info`)
```
## Development Notes
- 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.
- Responses always follow the `{ ok: boolean; data?; error? }` envelope to keep Prism and other clients stable.
## Testing ## Testing
Run the test suite with:
```bash ```bash
npm test npm test
``` ```
## Docs ## Related Repos
- [API overview](docs/api-overview.md) - `blackroad-os-operator` Agent runner and orchestration
- [OpenAPI spec](docs/openapi.yaml) - `blackroad-os-core` Domain primitives and journaling
- `blackroad-os-prism-console` Operator UI consuming this API
## TODO - `blackroad-os-web` Public web presence
- Add authentication and authorization.
- Add rate limiting and abuse protection.
- Improve structured logging and metrics.

View File

@@ -1,28 +1,27 @@
# BlackRoad OS API Overview # BlackRoad OS API Overview
The **blackroad-os-api** service is the public API gateway for the BlackRoad Operating System. It exposes lightweight monitoring endpoints and forwards finance-focused read APIs from the finance agents that live in **blackroad-os-operator**. `blackroad-os-api` is the versioned HTTP gateway for the BlackRoad Operating System. It shapes responses, enforces contracts, and forwards calls to internal services like `blackroad-os-operator` and future RoadChain storage.
## Responsibilities ## Responsibilities
- Provide health and version endpoints for infrastructure monitoring. - Provide typed, stable JSON endpoints for Prism Console and other clients.
- Surface finance insights (summary, cash forecasts, financial statements) produced by the operator's finance agents. - Normalize responses into a consistent `{ ok, data | error }` envelope.
- Act as the forward-compatible entry point for additional agent/task/compliance APIs. - Delegate business logic to Operator/Core while handling validation and error contracts at the edge.
## Architecture ## Endpoints (v1)
- **HTTP Server:** Express with centralized middleware for logging and error handling. - `GET /api/v1/health` API + dependency health summary.
- **Operator Client:** `HttpOperatorClient` bridges outbound requests to `blackroad-os-operator` using the configured `OPERATOR_BASE_URL`. - `GET /api/v1/system/overview` Aggregated status and recent metrics used by the Prism dashboard.
- **Types:** Finance contracts live in `src/types/finance.ts` and mirror GAAP-inspired outputs from the finance agents. - `GET /api/v1/agents` List agents with optional `status` and `q` search filters.
- `GET /api/v1/agents/:id` Agent detail or `AGENT_NOT_FOUND` on 404.
- `GET /api/v1/finance/snapshot` Treasury/finance snapshot (mocked until Operator wiring is live).
- `GET /api/v1/events` Recent journal-style events with optional `severity`, `source`, and `limit` filters.
- `GET /api/v1/roadchain/blocks` RoadChain block headers (mock data until journal backend is exposed).
## Authentication & Security ## Consumers
Authentication and authorization are not yet wired. Add API key or JWT validation middleware once requirements are defined. TODOs are in the codebase for rate limiting and structured observability. - **Prism Console** Dashboard, Agents, Finance, and Events views call these endpoints.
- **Operator/Core** Act as upstream data sources for health, agents, and finance metrics.
- **Web/Public surfaces** May use a narrowed subset later.
## Running Locally ## Notes
1. Install dependencies: `npm install` - Environment configuration is centralized in `src/config.ts` (`PORT`, `NODE_ENV`, `OPERATOR_API_BASE_URL`, `ROADCHAIN_BASE_URL`, `LOG_LEVEL`).
2. Start dev server: `npm run dev` - Mock data is clearly marked with TODOs to be replaced by real upstream calls.
3. Call endpoints: - Authentication/rate limiting are intentionally deferred until requirements are defined.
- `GET /health`
- `GET /version`
- `GET /finance/summary`
- `GET /finance/cash-forecast`
- `GET /finance/statements/{period}` (e.g., `2025-Q1`)
Finance endpoints are read-only views into the operator; any mutations belong in upstream agent services.

View File

@@ -1,64 +1,52 @@
import { getApiConfig } from "../config/env"; import { getConfig } from "../config";
import { CashForecast, FinanceSummary, FinancialStatements } from "../types/finance"; import { Agent, ServiceHealth } from "../types/api";
export interface OperatorClient { const MOCK_AGENTS: Agent[] = [
getFinanceSummary(): Promise<FinanceSummary>; {
getCashForecast(): Promise<CashForecast>; id: "agent-1",
getStatements(period: string): Promise<FinancialStatements>; name: "Atlas",
} role: "orchestrator",
status: "running",
/** lastHeartbeat: new Date().toISOString(),
* HTTP client for talking to blackroad-os-operator. version: "1.2.0",
* TODO: wire to real operator endpoints when available. tags: ["core", "ops"],
*/
export class HttpOperatorClient implements OperatorClient {
private readonly baseUrl: string;
private readonly timeoutMs: number;
constructor() {
const cfg = getApiConfig();
this.baseUrl = cfg.operatorBaseUrl;
this.timeoutMs = cfg.requestTimeoutMs;
}
private async get<T>(path: string): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
const res = await fetch(`${this.baseUrl}${path}`, {
method: "GET",
signal: controller.signal,
headers: {
"content-type": "application/json",
}, },
}).catch((err) => { {
clearTimeout(timeoutId); id: "agent-2",
throw err; name: "Ledger",
}); role: "finance",
status: "idle",
lastHeartbeat: new Date().toISOString(),
version: "0.9.5",
tags: ["finance", "treasury"],
},
];
clearTimeout(timeoutId); const MOCK_HEALTH: ServiceHealth[] = [
{
id: "operator",
name: "Operator",
status: "healthy",
latencyMs: 42,
lastChecked: new Date().toISOString(),
},
];
if (!res.ok) { export async function fetchOperatorHealth(): Promise<ServiceHealth[]> {
const text = await res.text().catch(() => ""); const { OPERATOR_API_BASE_URL } = getConfig();
const error = new Error(`Operator error ${res.status}: ${text}`); // TODO: Replace mock with real HTTP call to `${OPERATOR_API_BASE_URL}/health`.
(error as any).statusCode = 502; void OPERATOR_API_BASE_URL;
throw error; return MOCK_HEALTH;
} }
return res.json() as Promise<T>; export async function fetchAgents(): Promise<Agent[]> {
} const { OPERATOR_API_BASE_URL } = getConfig();
// TODO: Replace mock with real HTTP call to `${OPERATOR_API_BASE_URL}/agents`.
async getFinanceSummary(): Promise<FinanceSummary> { void OPERATOR_API_BASE_URL;
return this.get<FinanceSummary>("/internal/finance/summary"); return MOCK_AGENTS;
} }
async getCashForecast(): Promise<CashForecast> { export async function fetchAgentById(id: string): Promise<Agent | null> {
return this.get<CashForecast>("/internal/finance/cash-forecast"); const agents = await fetchAgents();
} return agents.find((agent) => agent.id === id) ?? null;
async getStatements(period: string): Promise<FinancialStatements> {
return this.get<FinancialStatements>(
`/internal/finance/statements/${encodeURIComponent(period)}`
);
}
} }

View File

@@ -0,0 +1,52 @@
import { EventRecord, RoadChainBlock } from "../types/api";
const BASE_HEIGHT = 1000;
function buildMockBlocks(): RoadChainBlock[] {
const now = Date.now();
return Array.from({ length: 5 }).map((_, idx) => {
const height = BASE_HEIGHT + idx;
const timestamp = new Date(now - idx * 60000).toISOString();
return {
height,
hash: `mock-hash-${height}`,
prevHash: `mock-hash-${height - 1}`,
timestamp,
eventIds: [`evt-${height}-1`, `evt-${height}-2`],
};
});
}
function buildMockEvents(blockHeight: number): EventRecord[] {
const block = blockHeight || BASE_HEIGHT;
const timestamp = new Date().toISOString();
return [
{
id: `evt-${block}-1`,
timestamp,
source: "operator",
type: "job.completed",
summary: `Job completed in block ${block}`,
psShaInfinity: "ps-sha-∞-example",
severity: "info",
},
{
id: `evt-${block}-2`,
timestamp,
source: "core",
type: "ledger.append",
summary: `Ledger entry added for block ${block}`,
severity: "warning",
},
];
}
export async function fetchRoadChainBlocks(): Promise<RoadChainBlock[]> {
// TODO: Replace with real RoadChain backend call when available.
return buildMockBlocks();
}
export async function fetchBlockEvents(blockHeight: number): Promise<EventRecord[]> {
// TODO: Replace with real RoadChain backend call when available.
return buildMockEvents(blockHeight);
}

39
src/config.ts Normal file
View File

@@ -0,0 +1,39 @@
import dotenv from "dotenv";
dotenv.config();
type NodeEnv = "development" | "test" | "production";
export type AppConfig = {
NODE_ENV: NodeEnv;
PORT: number;
LOG_LEVEL: string;
OPERATOR_API_BASE_URL: string;
ROADCHAIN_BASE_URL?: string;
};
function parseNumber(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function getConfig(): AppConfig {
const NODE_ENV = (process.env.NODE_ENV as NodeEnv) || "development";
const PORT = parseNumber(process.env.PORT, 4000);
const LOG_LEVEL = process.env.LOG_LEVEL || process.env.LOGLEVEL || "info";
const OPERATOR_API_BASE_URL =
process.env.OPERATOR_API_BASE_URL || "http://localhost:4100";
const ROADCHAIN_BASE_URL = process.env.ROADCHAIN_BASE_URL;
if (NODE_ENV === "production" && !process.env.OPERATOR_API_BASE_URL) {
throw new Error("OPERATOR_API_BASE_URL is required in production");
}
return {
NODE_ENV,
PORT,
LOG_LEVEL,
OPERATOR_API_BASE_URL,
ROADCHAIN_BASE_URL,
};
}

View File

@@ -1,53 +0,0 @@
/**
* Centralized API configuration loader.
*
* Values are sourced from environment variables and provide typed access for the
* HTTP server. The operator base URL points to the blackroad-os-operator
* service, which powers the finance agents.
*/
import dotenv from "dotenv";
dotenv.config();
export interface ApiConfig {
/**
* Deployment environment indicator (dev, staging, prod, test).
*/
env: "dev" | "staging" | "prod" | "test";
/**
* TCP port the HTTP server should bind to.
*/
port: number;
/**
* Log verbosity for request/response logging.
*/
logLevel: "debug" | "info" | "warn" | "error";
/**
* Base URL for the blackroad-os-operator service that provides finance data.
*/
operatorBaseUrl: string;
/**
* Timeout in milliseconds for outbound requests to upstream services.
*/
requestTimeoutMs: number;
}
/**
* Load and normalize API configuration from environment variables.
*/
export function getApiConfig(): ApiConfig {
const env = (process.env.NODE_ENV as ApiConfig["env"]) || "dev";
const port = Number.parseInt(process.env.PORT || "3001", 10);
const logLevel =
(process.env.LOG_LEVEL as ApiConfig["logLevel"]) || process.env.LOGLEVEL || "info";
const operatorBaseUrl = process.env.OPERATOR_BASE_URL || "http://localhost:4000";
const requestTimeoutMs = Number.parseInt(process.env.REQUEST_TIMEOUT_MS || "5000", 10);
return {
env,
port: Number.isFinite(port) ? port : 3001,
logLevel,
operatorBaseUrl,
requestTimeoutMs: Number.isFinite(requestTimeoutMs) ? requestTimeoutMs : 5000,
};
}

View File

@@ -1,22 +0,0 @@
export const SERVICE_ID = "api";
export const SERVICE_NAME = "BlackRoad OS Public API";
export const SERVICE_BASE_URL =
process.env.SERVICE_BASE_URL || "https://api.blackroad.systems";
export const OS_ROOT = process.env.OS_ROOT || "https://blackroad.systems";
export const CORE_BASE_URL =
process.env.CORE_BASE_URL || "https://core.blackroad.systems";
export const CORE_VERIFICATION_BASE_URL =
process.env.CORE_VERIFICATION_BASE_URL || `${CORE_BASE_URL}/internal`;
export const OPERATOR_BASE_URL =
process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems";
export const serviceConfig = {
SERVICE_ID,
SERVICE_NAME,
SERVICE_BASE_URL,
OS_ROOT,
CORE_BASE_URL,
CORE_VERIFICATION_BASE_URL,
OPERATOR_BASE_URL,
};

View File

@@ -1,12 +1,12 @@
import { createServer } from "./server"; import { createApp } from "./server";
import { getApiConfig } from "./config/env"; import { getConfig } from "./config";
async function main() { async function main() {
const cfg = getApiConfig(); const config = getConfig();
const app = createServer(); const app = createApp();
app.listen(cfg.port, "0.0.0.0", () => { app.listen(config.PORT, "0.0.0.0", () => {
console.log(`blackroad-os-api listening on port ${cfg.port} (${cfg.env})`); console.log(`blackroad-os-api listening on port ${config.PORT} (${config.NODE_ENV})`);
}); });
} }

View File

@@ -1,28 +1,33 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { getConfig } from "../config";
export interface ApiError extends Error { export interface ApiRouteError extends Error {
statusCode?: number; statusCode?: number;
code?: string;
details?: unknown; details?: unknown;
} }
/** export function errorHandler(
* Express error handler that ensures consistent JSON error responses. err: ApiRouteError,
*/ req: Request,
export function errorHandler(err: ApiError, req: Request, res: Response, _next: NextFunction) { res: Response,
_next: NextFunction
) {
const { NODE_ENV } = getConfig();
const statusCode = err.statusCode || 500; const statusCode = err.statusCode || 500;
const code = err.code || (statusCode >= 500 ? "INTERNAL_SERVER_ERROR" : "BAD_REQUEST");
const requestId = (req as any).requestId; const requestId = (req as any).requestId;
// TODO: formalize error code taxonomy and mapping from upstream services.
const payload = {
error: {
message: statusCode === 500 ? "Internal server error" : err.message,
statusCode,
requestId,
details: statusCode === 500 ? undefined : err.details,
},
};
if (statusCode >= 500) {
console.error(`[${requestId || "unknown"}]`, err); console.error(`[${requestId || "unknown"}]`, err);
}
res.status(statusCode).json(payload); res.status(statusCode).json({
ok: false,
error: {
code,
message: statusCode >= 500 ? "Internal server error" : err.message,
details: NODE_ENV === "development" || NODE_ENV === "test" ? err.details ?? err.stack : undefined,
},
});
} }

View File

@@ -1,24 +0,0 @@
import { randomUUID } from "crypto";
import { NextFunction, Request, Response } from "express";
import { getApiConfig } from "../config/env";
/**
* Basic request logger that also ensures every request has a request ID.
* A structured logger can replace the console usage in the future.
*/
export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
const cfg = getApiConfig();
const requestId = req.headers["x-request-id"] || randomUUID();
(req as any).requestId = requestId;
res.setHeader("x-request-id", String(requestId));
res.on("finish", () => {
const duration = Date.now() - start;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`;
console.log(`[${requestId}] ${cfg.logLevel.toUpperCase()} ${message}`);
});
next();
}

View File

@@ -0,0 +1,20 @@
import { randomUUID } from "crypto";
import { NextFunction, Request, Response } from "express";
import { getConfig } from "../config";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
const { LOG_LEVEL } = getConfig();
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
(req as any).requestId = requestId;
res.setHeader("x-request-id", String(requestId));
res.on("finish", () => {
const duration = Date.now() - start;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`;
console.log(`[${requestId}] ${LOG_LEVEL.toUpperCase()} ${message}`);
});
next();
}

48
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Router } from "express";
import { fetchAgentById, fetchAgents } from "../clients/operatorClient";
import { ApiRouteError } from "../middleware/errorHandler";
import { Agent, ApiResponse } from "../types/api";
export function createAgentsRouter() {
const router = Router();
router.get("/", async (req, res, next) => {
try {
const { status, q } = req.query;
const agents = await fetchAgents();
const filtered = agents.filter((agent) => {
const statusMatch = status ? agent.status === status : true;
const query = (q as string | undefined)?.toLowerCase();
const queryMatch = query
? 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 };
res.json(response);
} catch (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);
}
});
return router;
}

49
src/routes/events.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Router } from "express";
import { ApiResponse, EventRecord } from "../types/api";
const MOCK_EVENTS: EventRecord[] = [
{
id: "evt-001",
timestamp: new Date().toISOString(),
source: "operator",
type: "job.started",
summary: "Orchestrator kicked off batch",
severity: "info",
},
{
id: "evt-002",
timestamp: new Date(Date.now() - 60000).toISOString(),
source: "core",
type: "ledger.append",
summary: "RoadChain journal entry committed",
psShaInfinity: "ps-sha-∞-example",
severity: "warning",
},
{
id: "evt-003",
timestamp: new Date(Date.now() - 120000).toISOString(),
source: "finance",
type: "forecast.updated",
summary: "Finance snapshot refreshed",
severity: "info",
},
];
export function createEventsRouter() {
const router = Router();
router.get("/", (req, res) => {
const { limit = "50", severity, source } = req.query;
const parsedLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
const filtered = MOCK_EVENTS.filter((event) => {
const severityMatch = severity ? event.severity === severity : true;
const sourceMatch = source ? event.source === source : true;
return severityMatch && sourceMatch;
}).slice(0, parsedLimit);
const response: ApiResponse<EventRecord[]> = { ok: true, data: filtered };
res.json(response);
});
return router;
}

View File

@@ -1,46 +1,28 @@
import { Router } from "express"; import { Router } from "express";
import { HttpOperatorClient, OperatorClient } from "../clients/operatorClient"; import { ApiResponse, FinanceSnapshot } from "../types/api";
import { ApiError } from "../middleware/errorHandler";
const PERIOD_REGEX = /^(\d{4}-(0[1-9]|1[0-2])|\d{4}-Q[1-4])$/; function buildMockFinanceSnapshot(): FinanceSnapshot {
const now = new Date().toISOString();
return {
timestamp: now,
monthlyInfraCostUsd: 4200,
monthlyRevenueUsd: 12500,
estimatedSavingsUsd: 3100,
walletBalanceUsd: 88000,
notes: "Mock finance snapshot. Wire to real treasury metrics in operator/core.",
};
}
export function createFinanceRouter(operatorClient: OperatorClient = new HttpOperatorClient()): Router { export function createFinanceRouter() {
const router = Router(); const router = Router();
router.get("/summary", async (_req, res, next) => { router.get("/snapshot", (_req, res) => {
try { const response: ApiResponse<FinanceSnapshot> = {
const summary = await operatorClient.getFinanceSummary(); ok: true,
res.json({ data: summary }); data: buildMockFinanceSnapshot(),
} catch (err) { };
next(err);
}
});
router.get("/cash-forecast", async (_req, res, next) => { res.json(response);
try {
const forecast = await operatorClient.getCashForecast();
res.json({ data: forecast });
} catch (err) {
next(err);
}
});
router.get("/statements/:period", async (req, res, next) => {
try {
const { period } = req.params;
if (!PERIOD_REGEX.test(period)) {
const error: ApiError = new Error(
"Invalid period format. Use YYYY-MM or YYYY-Q{1-4}."
);
error.statusCode = 400;
throw error;
}
const statements = await operatorClient.getStatements(period);
res.json({ data: statements });
} catch (err) {
next(err);
}
}); });
return router; return router;

View File

@@ -1,16 +1,43 @@
import { Router } from "express"; import { Router } from "express";
import { getApiConfig } from "../config/env"; import { fetchOperatorHealth } from "../clients/operatorClient";
import { ApiResponse, ServiceHealth } from "../types/api";
export function createHealthRouter(): Router { function computeOverallStatus(services: ServiceHealth[]): ServiceHealth["status"] {
if (services.some((service) => service.status === "down")) return "down";
if (services.some((service) => service.status === "degraded")) return "degraded";
return "healthy";
}
export function createHealthRouter() {
const router = Router(); const router = Router();
router.get("/", (_req, res) => { router.get("/", async (_req, res, next) => {
const cfg = getApiConfig(); try {
res.json({ const services: ServiceHealth[] = [
status: "ok", {
uptimeSeconds: Math.round(process.uptime()), id: "api",
env: cfg.env, name: "API Gateway",
}); status: "healthy",
lastChecked: new Date().toISOString(),
},
...((await fetchOperatorHealth()) || []),
];
const response: ApiResponse<{
overallStatus: ServiceHealth["status"];
services: ServiceHealth[];
}> = {
ok: true,
data: {
overallStatus: computeOverallStatus(services),
services,
},
};
res.json(response);
} catch (err) {
next(err);
}
}); });
return router; return router;

View File

@@ -1,10 +1,20 @@
import { Express } from "express"; import { Router } from "express";
import { createAgentsRouter } from "./agents";
import { createEventsRouter } from "./events";
import { createFinanceRouter } from "./finance"; import { createFinanceRouter } from "./finance";
import { createHealthRouter } from "./health"; import { createHealthRouter } from "./health";
import { createVersionRouter } from "./version"; import { createRoadchainRouter } from "./roadchain";
import { createSystemRouter } from "./system";
export function registerRoutes(app: Express) { export function createV1Router() {
app.use("/health", createHealthRouter()); const router = Router();
app.use("/version", createVersionRouter());
app.use("/finance", createFinanceRouter()); router.use("/health", createHealthRouter());
router.use("/system", createSystemRouter());
router.use("/agents", createAgentsRouter());
router.use("/finance", createFinanceRouter());
router.use("/events", createEventsRouter());
router.use("/roadchain", createRoadchainRouter());
return router;
} }

19
src/routes/roadchain.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Router } from "express";
import { fetchRoadChainBlocks } from "../clients/roadchainClient";
import { ApiResponse, RoadChainBlock } from "../types/api";
export function createRoadchainRouter() {
const router = Router();
router.get("/blocks", async (_req, res, next) => {
try {
const blocks = await fetchRoadChainBlocks();
const response: ApiResponse<RoadChainBlock[]> = { ok: true, data: blocks };
res.json(response);
} catch (err) {
next(err);
}
});
return router;
}

43
src/routes/system.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Router } from "express";
import { fetchOperatorHealth } from "../clients/operatorClient";
import { ApiResponse, ServiceHealth, SystemOverview } from "../types/api";
function summarizeStatus(services: ServiceHealth[]): SystemOverview["overallStatus"] {
if (services.some((service) => service.status === "down")) return "down";
if (services.some((service) => service.status === "degraded")) return "degraded";
return "healthy";
}
export function createSystemRouter() {
const router = Router();
router.get("/overview", async (_req, res, next) => {
try {
const services: ServiceHealth[] = [
{
id: "api",
name: "API Gateway",
status: "healthy",
latencyMs: 1,
lastChecked: new Date().toISOString(),
},
...((await fetchOperatorHealth()) || []),
];
const overview: SystemOverview = {
overallStatus: summarizeStatus(services),
services,
jobsProcessedLast24h: 128,
errorsLast24h: 2,
notes: "Mocked system overview. Replace with metrics from Operator and observability stack.",
};
const response: ApiResponse<SystemOverview> = { ok: true, data: overview };
res.json(response);
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -1,27 +0,0 @@
import { Router } from "express";
import packageInfo from "../../package.json";
import { getApiConfig } from "../config/env";
export function createVersionRouter(): Router {
const router = Router();
router.get("/", (_req, res) => {
const cfg = getApiConfig();
const payload: Record<string, unknown> = {
version: packageInfo.version,
env: cfg.env,
};
if (process.env.GIT_SHA) {
payload.gitSha = process.env.GIT_SHA;
}
if (process.env.BUILD_TIME) {
payload.buildTime = process.env.BUILD_TIME;
}
res.json(payload);
});
return router;
}

View File

@@ -1,20 +1,27 @@
import express from "express";
import cors from "cors"; import cors from "cors";
import { loggingMiddleware } from "./middleware/logging"; import express from "express";
import { errorHandler } from "./middleware/errorHandler"; import { errorHandler } from "./middleware/errorHandler";
import { registerRoutes } from "./routes"; import { requestLogger } from "./middleware/requestLogger";
import { createV1Router } from "./routes";
export function createServer() { export function createApp() {
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(loggingMiddleware); app.use(requestLogger);
// TODO: add rate limiting/abuse protection middleware when requirements are defined.
registerRoutes(app); app.use("/api/v1", createV1Router());
app.use((req, res) => {
res.status(404).json({
ok: false,
error: {
code: "NOT_FOUND",
message: `Route not found: ${req.method} ${req.originalUrl}`,
},
});
});
// TODO: add auth middleware when authN/authZ requirements are defined.
// TODO: emit metrics/traces once observability stack is available.
app.use(errorHandler); app.use(errorHandler);
return app; return app;

68
src/types/api.ts Normal file
View File

@@ -0,0 +1,68 @@
export type ApiSuccess<T> = {
ok: true;
data: T;
};
export type ApiError = {
ok: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccess<T> | ApiError;
export type ServiceHealth = {
id: string;
name: string;
status: "healthy" | "degraded" | "down";
latencyMs?: number;
lastChecked: string;
};
export type SystemOverview = {
overallStatus: "healthy" | "degraded" | "down";
services: ServiceHealth[];
jobsProcessedLast24h?: number;
errorsLast24h?: number;
notes?: string;
};
export type Agent = {
id: string;
name: string;
role: string;
status: "idle" | "running" | "error" | "offline";
lastHeartbeat: string;
version?: string;
tags?: string[];
};
export type FinanceSnapshot = {
timestamp: string;
monthlyInfraCostUsd?: number;
monthlyRevenueUsd?: number;
estimatedSavingsUsd?: number;
walletBalanceUsd?: number;
notes?: string;
};
export type EventRecord = {
id: string;
timestamp: string;
source: string;
type: string;
summary: string;
psShaInfinity?: string;
severity?: "info" | "warning" | "error";
};
export type RoadChainBlock = {
height: number;
hash: string;
prevHash: string;
timestamp: string;
eventIds: string[];
};

View File

@@ -1,106 +1,14 @@
import express from "express";
import request from "supertest"; import request from "supertest";
import { createFinanceRouter } from "../src/routes/finance"; import { createApp } from "../src/server";
import { errorHandler } from "../src/middleware/errorHandler";
import { OperatorClient } from "../src/clients/operatorClient";
import { CashForecast, FinanceSummary, FinancialStatements } from "../src/types/finance";
describe("finance routes", () => { const app = createApp();
let app: express.Express;
let operatorClient: jest.Mocked<OperatorClient>;
const summaryFixture: FinanceSummary = { describe("GET /api/v1/finance/snapshot", () => {
currency: "USD", it("returns a finance snapshot in the standard envelope", async () => {
cashBalance: 100000, const res = await request(app).get("/api/v1/finance/snapshot").expect(200);
monthlyBurnRate: 20000,
runwayMonths: 5,
mrr: 50000,
arr: 600000,
generatedAt: new Date().toISOString(),
};
const forecastFixture: CashForecast = { expect(res.body.ok).toBe(true);
currency: "USD", expect(res.body.data.timestamp).toBeDefined();
generatedAt: new Date().toISOString(), expect(res.body.data.walletBalanceUsd).toBeDefined();
buckets: [
{ startDate: "2024-01-01", endDate: "2024-01-31", netChange: -10000, endingBalance: 90000 },
],
};
const statementsFixture: FinancialStatements = {
period: "2024-Q1",
incomeStatement: {
period: "2024-Q1",
currency: "USD",
revenue: [{ account: "4000", label: "Revenue", amount: 150000 }],
cogs: [{ account: "5000", label: "COGS", amount: 50000 }],
operatingExpenses: [{ account: "6000", label: "Opex", amount: 75000 }],
otherIncomeExpenses: [],
netIncome: 25000,
},
balanceSheet: {
period: "2024-Q1",
currency: "USD",
assets: [{ account: "1000", label: "Cash", amount: 100000 }],
liabilities: [{ account: "2000", label: "AP", amount: 30000 }],
equity: [{ account: "3000", label: "Equity", amount: 70000 }],
},
cashFlowStatement: {
period: "2024-Q1",
currency: "USD",
operatingActivities: [{ account: "7000", label: "Ops", amount: 20000 }],
investingActivities: [],
financingActivities: [],
netChangeInCash: 20000,
},
};
beforeEach(() => {
operatorClient = {
getFinanceSummary: jest.fn().mockResolvedValue(summaryFixture),
getCashForecast: jest.fn().mockResolvedValue(forecastFixture),
getStatements: jest.fn().mockResolvedValue(statementsFixture),
};
app = express();
app.use(express.json());
app.use("/finance", createFinanceRouter(operatorClient));
app.use(errorHandler);
});
it("returns finance summary", async () => {
const res = await request(app).get("/finance/summary").expect(200);
expect(res.body.data).toEqual(summaryFixture);
expect(operatorClient.getFinanceSummary).toHaveBeenCalledTimes(1);
});
it("returns cash forecast", async () => {
const res = await request(app).get("/finance/cash-forecast").expect(200);
expect(res.body.data).toEqual(forecastFixture);
expect(operatorClient.getCashForecast).toHaveBeenCalledTimes(1);
});
it("returns statements for valid period", async () => {
const res = await request(app).get("/finance/statements/2024-Q1").expect(200);
expect(res.body.data).toEqual(statementsFixture);
expect(operatorClient.getStatements).toHaveBeenCalledWith("2024-Q1");
});
it("rejects invalid period", async () => {
const res = await request(app).get("/finance/statements/2024-13").expect(400);
expect(res.body.error.message).toContain("Invalid period format");
expect(operatorClient.getStatements).not.toHaveBeenCalled();
});
it("propagates upstream errors", async () => {
operatorClient.getFinanceSummary.mockRejectedValueOnce(new Error("boom"));
const res = await request(app).get("/finance/summary").expect(500);
expect(res.body.error.message).toBe("Internal server error");
}); });
}); });

View File

@@ -1,14 +1,14 @@
import request from "supertest"; import request from "supertest";
import { createServer } from "../src/server"; import { createApp } from "../src/server";
const app = createServer(); const app = createApp();
describe("GET /health", () => { describe("GET /api/v1/health", () => {
it("returns ok with uptime and env", async () => { it("returns ok with overall status and services", async () => {
const res = await request(app).get("/health").expect(200); const res = await request(app).get("/api/v1/health").expect(200);
expect(res.body.status).toBe("ok"); expect(res.body.ok).toBe(true);
expect(typeof res.body.uptimeSeconds).toBe("number"); expect(res.body.data.overallStatus).toBeDefined();
expect(res.body.env).toBeDefined(); expect(Array.isArray(res.body.data.services)).toBe(true);
}); });
}); });