feat: scaffold finance api gateway

This commit is contained in:
Alexa Amundson
2025-11-23 14:42:21 -06:00
parent 2cc320dd17
commit cc730a156f
29 changed files with 807 additions and 1021 deletions

126
README.md
View File

@@ -1,125 +1,57 @@
# BlackRoad OS Public API # BlackRoad OS Public API
Public API gateway for the BlackRoad Operating System. This service fronts the platform's public-facing endpoints and relays metadata about the OS ecosystem services. 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**.
## Overview ## Features
- **Service Name:** BlackRoad OS Public API - **Health & Version** endpoints for observability.
- **Service ID:** `api` - **Finance** endpoints for summaries, cash forecasts, and financial statements.
- **Base URL:** https://api.blackroad.systems - Structured, testable Express setup with centralized middleware.
- **Default Port:** 8080
## Endpoints
- `GET /health` Liveness check returning service id and timestamp.
- `GET /info` Service metadata including base URL, OS root, and version.
- `GET /version` Service id and version from `package.json`.
- `GET /debug/env` Safe subset of environment configuration.
- `GET /v1/health` Versioned health check endpoint.
- `GET /v1/ping` Versioned ping endpoint for API consumers.
## Getting Started ## Getting Started
1. Install dependencies 1. Install dependencies:
```bash ```bash
npm install npm install
``` ```
2. Run in development mode (with live reload) 2. Run in development mode:
```bash ```bash
npm run dev npm run dev
``` ```
3. Build for production 3. Build for production:
```bash ```bash
npm run build npm run build
``` ```
4. Start the compiled server 4. Start the compiled server:
```bash ```bash
npm start npm start
``` ```
The server listens on `http://localhost:8080` by default or `PORT` if provided. The server listens on `http://localhost:3001` by default or the configured `PORT`.
## Environment Variables ## Configuration
See `.env.example` for defaults: Set the following environment variables as needed:
- `OS_ROOT` Base URL for the BlackRoad OS - `PORT` Port to bind (default `3001`)
- `SERVICE_BASE_URL` External URL for this public API - `LOG_LEVEL` Logging verbosity (default `info`)
- `CORE_BASE_URL` Core service base URL - `OPERATOR_BASE_URL` Base URL for `blackroad-os-operator`
- `OPERATOR_BASE_URL` Operator service base URL - `REQUEST_TIMEOUT_MS` Timeout for upstream requests (default `5000`)
- `LOG_LEVEL` Logging verbosity
- `PORT` Port to bind (default `8080`)
## Railway Deployment ## Example Requests
The repository is configured for Railway deployment using the modern 2024 format: ```bash
curl http://localhost:3001/health
**Configuration Files:** curl http://localhost:3001/finance/summary
- `railway.json` - Railway deployment configuration with schema validation curl http://localhost:3001/finance/statements/2025-Q1
- `nixpacks.toml` - Explicit build configuration for Node.js 20 ```
**Deployment Settings:**
- Builder: Nixpacks
- Build: `npm install && npm run build`
- Start: `npm start`
- Healthcheck: `/health` (timeout: 100s)
- Restart Policy: Always
The service will automatically deploy to Railway when changes are pushed to the configured branches (dev, staging, main).
## Testing ## Testing
Run the test suite with: Run the test suite with:
```bash ```bash
npm test npm test
``` ```
# BlackRoad OS API
A minimal FastAPI service for BlackRoad OS with health and version endpoints. ## Docs
- [API overview](docs/api-overview.md)
- [OpenAPI spec](docs/openapi.yaml)
## Project Structure ## TODO
- Add authentication and authorization.
``` - Add rate limiting and abuse protection.
. - Improve structured logging and metrics.
├── app/ # FastAPI application
│ ├── __init__.py # Package initialization with version
│ └── main.py # Main application with /health and /version endpoints
├── schemas/ # Pydantic models for requests/responses
│ ├── __init__.py
│ └── responses.py # Response models
└── infra/ # Infrastructure and deployment files
├── Dockerfile # Docker container configuration
├── requirements.txt # Python dependencies
└── railway.toml # Railway.app deployment configuration
```
## Getting Started
### Local Development
1. Install dependencies:
```bash
pip install -r infra/requirements.txt
```
2. Run the server:
```bash
uvicorn app.main:app --reload
```
3. Access the API:
- Health check: http://localhost:8000/health
- Version: http://localhost:8000/version
- API docs: http://localhost:8000/docs
### Docker
Build and run with Docker:
```bash
docker build -f infra/Dockerfile -t blackroad-os-api .
docker run -p 8000:8000 blackroad-os-api
```
### Deploy to Railway
This project includes a `railway.toml` configuration file for easy deployment to Railway.app.
## API Endpoints
- `GET /health` - Health check endpoint returning `{"status": "ok"}`
- `GET /version` - Version endpoint returning `{"version": "0.1.0"}`
- `GET /docs` - Interactive API documentation (Swagger UI)
- `GET /redoc` - Alternative API documentation (ReDoc)

28
docs/api-overview.md Normal file
View File

@@ -0,0 +1,28 @@
# 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**.
## Responsibilities
- Provide health and version endpoints for infrastructure monitoring.
- Surface finance insights (summary, cash forecasts, financial statements) produced by the operator's finance agents.
- Act as the forward-compatible entry point for additional agent/task/compliance APIs.
## Architecture
- **HTTP Server:** Express with centralized middleware for logging and error handling.
- **Operator Client:** `HttpOperatorClient` bridges outbound requests to `blackroad-os-operator` using the configured `OPERATOR_BASE_URL`.
- **Types:** Finance contracts live in `src/types/finance.ts` and mirror GAAP-inspired outputs from the finance agents.
## Authentication & Security
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.
## Running Locally
1. Install dependencies: `npm install`
2. Start dev server: `npm run dev`
3. Call endpoints:
- `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.

230
docs/openapi.yaml Normal file
View File

@@ -0,0 +1,230 @@
openapi: 3.0.3
info:
title: BlackRoad OS API
version: 0.1.0
servers:
- url: http://localhost:3001
description: Local development
- url: https://api.blackroad.systems
description: Production gateway
paths:
/health:
get:
summary: Health check
responses:
"200":
description: Service health
content:
application/json:
schema:
type: object
properties:
status:
type: string
uptimeSeconds:
type: number
env:
type: string
/version:
get:
summary: Version info
responses:
"200":
description: Service version metadata
content:
application/json:
schema:
type: object
properties:
version:
type: string
gitSha:
type: string
buildTime:
type: string
env:
type: string
/finance/summary:
get:
summary: Finance summary
responses:
"200":
description: Finance snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/FinanceSummaryResponse'
/finance/cash-forecast:
get:
summary: Cash forecast
responses:
"200":
description: Cash forecast buckets
content:
application/json:
schema:
$ref: '#/components/schemas/CashForecastResponse'
/finance/statements/{period}:
get:
summary: Financial statements
parameters:
- in: path
name: period
required: true
description: Period identifier (YYYY-MM or YYYY-Qn)
schema:
type: string
responses:
"200":
description: Financial statements for period
content:
application/json:
schema:
$ref: '#/components/schemas/FinancialStatementsResponse'
"400":
description: Invalid period
components:
schemas:
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
format: date-time
CashForecastBucket:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
netChange:
type: number
endingBalance:
type: number
CashForecast:
type: object
properties:
currency:
type: string
buckets:
type: array
items:
$ref: '#/components/schemas/CashForecastBucket'
generatedAt:
type: string
format: date-time
StatementLineItem:
type: object
properties:
account:
type: string
label:
type: string
amount:
type: number
IncomeStatement:
type: object
properties:
period:
type: string
currency:
type: string
revenue:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
cogs:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
operatingExpenses:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
otherIncomeExpenses:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
netIncome:
type: number
BalanceSheet:
type: object
properties:
period:
type: string
currency:
type: string
assets:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
liabilities:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
equity:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
CashFlowStatement:
type: object
properties:
period:
type: string
currency:
type: string
operatingActivities:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
investingActivities:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
financingActivities:
type: array
items:
$ref: '#/components/schemas/StatementLineItem'
netChangeInCash:
type: number
FinancialStatements:
type: object
properties:
period:
type: string
incomeStatement:
$ref: '#/components/schemas/IncomeStatement'
balanceSheet:
$ref: '#/components/schemas/BalanceSheet'
cashFlowStatement:
$ref: '#/components/schemas/CashFlowStatement'
FinanceSummaryResponse:
type: object
properties:
data:
$ref: '#/components/schemas/FinanceSummary'
CashForecastResponse:
type: object
properties:
data:
$ref: '#/components/schemas/CashForecast'
FinancialStatementsResponse:
type: object
properties:
data:
$ref: '#/components/schemas/FinancialStatements'

View File

@@ -4,9 +4,9 @@
"description": "BlackRoad OS Public API gateway service", "description": "BlackRoad OS Public API gateway service",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"dev": "ts-node src/server.ts", "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/server.js", "start": "node dist/index.js",
"test": "NODE_ENV=test jest" "test": "NODE_ENV=test jest"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,64 @@
import { getApiConfig } from "../config/env";
import { CashForecast, FinanceSummary, FinancialStatements } from "../types/finance";
export interface OperatorClient {
getFinanceSummary(): Promise<FinanceSummary>;
getCashForecast(): Promise<CashForecast>;
getStatements(period: string): Promise<FinancialStatements>;
}
/**
* HTTP client for talking to blackroad-os-operator.
* TODO: wire to real operator endpoints when available.
*/
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);
throw err;
});
clearTimeout(timeoutId);
if (!res.ok) {
const text = await res.text().catch(() => "");
const error = new Error(`Operator error ${res.status}: ${text}`);
(error as any).statusCode = 502;
throw error;
}
return res.json() as Promise<T>;
}
async getFinanceSummary(): Promise<FinanceSummary> {
return this.get<FinanceSummary>("/internal/finance/summary");
}
async getCashForecast(): Promise<CashForecast> {
return this.get<CashForecast>("/internal/finance/cash-forecast");
}
async getStatements(period: string): Promise<FinancialStatements> {
return this.get<FinancialStatements>(
`/internal/finance/statements/${encodeURIComponent(period)}`
);
}
}

View File

@@ -1,22 +1,53 @@
/**
* 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"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const parsePort = (value: string | undefined, fallback: number): number => { export interface ApiConfig {
const parsed = Number(value); /**
return Number.isFinite(parsed) ? parsed : fallback; * 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;
}
const defaultCoreBaseUrl = process.env.CORE_BASE_URL || "http://localhost:3001"; /**
* 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);
export const env = { return {
PORT: parsePort(process.env.PORT, 8080), env,
HOST: process.env.HOST || "0.0.0.0", port: Number.isFinite(port) ? port : 3001,
CORE_BASE_URL: defaultCoreBaseUrl, logLevel,
CORE_VERIFICATION_BASE_URL: operatorBaseUrl,
process.env.CORE_VERIFICATION_BASE_URL || `${defaultCoreBaseUrl}/internal`, requestTimeoutMs: Number.isFinite(requestTimeoutMs) ? requestTimeoutMs : 5000,
AGENTS_BASE_URL: process.env.AGENTS_BASE_URL || "http://localhost:3002", };
OPERATOR_BASE_URL: process.env.OPERATOR_BASE_URL || "http://localhost:3003", }
SERVICE_VERSION:
process.env.SERVICE_VERSION || process.env.npm_package_version || "dev",
};

View File

@@ -1,33 +1,16 @@
import express from "express"; import { createServer } from "./server";
import { createProxyRouter } from "./routes/proxy"; import { getApiConfig } from "./config/env";
import { serviceClients } from "./lib/httpClient";
import health from "./routes/health";
import infoRouter from "./routes/info";
import versionRouter from "./routes/version";
import pingRouter from "./routes/v1/ping";
import v1HealthRouter from "./routes/v1/health";
import verificationRouter from "./routes/v1/verify";
const app = express(); async function main() {
const cfg = getApiConfig();
const app = createServer();
app.use(express.json({ limit: "5mb" })); app.listen(cfg.port, "0.0.0.0", () => {
console.log(`blackroad-os-api listening on port ${cfg.port} (${cfg.env})`);
});
}
// API routes main().catch((err) => {
app.use(health); console.error("Fatal error starting API:", err);
app.use(infoRouter); process.exit(1);
app.use(versionRouter);
app.use("/v1", pingRouter);
app.use("/v1", v1HealthRouter);
app.use("/v1", verificationRouter);
// Proxy routes
app.use("/core", createProxyRouter(serviceClients.core));
app.use("/agents", createProxyRouter(serviceClients.agents));
app.use("/operator", createProxyRouter(serviceClients.operator));
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(502).json({ error: "Upstream request failed" });
}); });
export default app;

View File

@@ -1,160 +0,0 @@
import axios, { AxiosError, AxiosInstance } from "axios";
import { env } from "../config/env";
const DEFAULT_TIMEOUT_MS = 15_000;
export type VerificationJobPayload = {
text: string;
source_uri?: string;
author_id?: string;
claim_hash?: string;
domain?: string;
policy_id?: string;
requested_by?: string;
};
export type CoreAssessment = {
agent_id?: string;
verdict?: string;
confidence?: number;
created_at?: string;
evidence_uris?: string[];
};
export type CoreSnapshot = {
id: string;
source_uri?: string;
author_id?: string;
parent_snapshot_id?: string | null;
created_at?: string;
ps_sha_infinity?: string;
};
export type CoreTruthState = {
claim_hash: string;
status: string;
aggregate_confidence?: number | null;
job_ids?: string[];
minority_reports?: string[];
last_updated?: string;
policy_id?: string;
domain?: string;
};
export type CoreVerificationJob = {
job_id: string;
snapshot_id?: string;
claim_hash?: string;
status: string;
created_at?: string;
domain?: string;
policy_id?: string;
truth_state?: CoreTruthState | null;
snapshot?: CoreSnapshot;
assessments?: CoreAssessment[];
};
export type CoreProvenanceGraph = {
snapshot: CoreSnapshot;
parent_snapshot?: CoreSnapshot | null;
derived_snapshots?: CoreSnapshot[];
related_jobs?: { id: string; status?: string }[];
ledger_entries?: { id: string; type: string }[];
};
export class CoreVerificationError extends Error {
status?: number;
data?: any;
constructor(message: string, status?: number, data?: any) {
super(message);
this.status = status;
this.data = data;
}
}
const createClient = (): AxiosInstance =>
axios.create({
baseURL: env.CORE_VERIFICATION_BASE_URL,
timeout: DEFAULT_TIMEOUT_MS,
});
const mapAxiosError = (error: AxiosError): CoreVerificationError => {
const status = error.response?.status;
const data = error.response?.data;
const message =
(data && typeof data === "object" && "message" in data
? (data as Record<string, any>).message
: undefined) || error.message;
return new CoreVerificationError(message, status, data);
};
export interface VerificationServiceClient {
createVerificationJob(
payload: VerificationJobPayload
): Promise<CoreVerificationJob>;
getVerificationJob(jobId: string): Promise<CoreVerificationJob>;
getTruthState(claimHash: string): Promise<CoreTruthState>;
getProvenance(snapshotId: string): Promise<CoreProvenanceGraph>;
}
export class CoreVerificationClient implements VerificationServiceClient {
private client: AxiosInstance;
constructor(client: AxiosInstance = createClient()) {
this.client = client;
}
async createVerificationJob(
payload: VerificationJobPayload
): Promise<CoreVerificationJob> {
try {
const response = await this.client.post("/verification/jobs", payload);
return response.data as CoreVerificationJob;
} catch (error) {
if (axios.isAxiosError(error)) {
throw mapAxiosError(error);
}
throw error;
}
}
async getVerificationJob(jobId: string): Promise<CoreVerificationJob> {
try {
const response = await this.client.get(`/verification/jobs/${jobId}`);
return response.data as CoreVerificationJob;
} catch (error) {
if (axios.isAxiosError(error)) {
throw mapAxiosError(error);
}
throw error;
}
}
async getTruthState(claimHash: string): Promise<CoreTruthState> {
try {
const response = await this.client.get(`/truth/${claimHash}`);
return response.data as CoreTruthState;
} catch (error) {
if (axios.isAxiosError(error)) {
throw mapAxiosError(error);
}
throw error;
}
}
async getProvenance(snapshotId: string): Promise<CoreProvenanceGraph> {
try {
const response = await this.client.get(`/provenance/${snapshotId}`);
return response.data as CoreProvenanceGraph;
} catch (error) {
if (axios.isAxiosError(error)) {
throw mapAxiosError(error);
}
throw error;
}
}
}
export const coreVerificationClient = new CoreVerificationClient();

View File

@@ -1,42 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios";
import type { Request } from "express";
import { env } from "../config/env";
type TargetService = "core" | "agents" | "operator";
const DEFAULT_TIMEOUT_MS = 15_000;
const createClient = (baseURL: string): AxiosInstance =>
axios.create({
baseURL,
timeout: DEFAULT_TIMEOUT_MS,
});
export const serviceClients: Record<TargetService, AxiosInstance> = {
core: createClient(env.CORE_BASE_URL),
agents: createClient(env.AGENTS_BASE_URL),
operator: createClient(env.OPERATOR_BASE_URL),
};
export const forwardRequest = async (
client: AxiosInstance,
req: Request
) => {
const method = req.method.toUpperCase() as Method;
const config: AxiosRequestConfig = {
url: req.originalUrl.replace(/^\/[a-z]+/, ""),
method,
params: req.query,
data: req.body,
headers: {
...req.headers,
host: undefined,
connection: undefined,
"content-length": undefined,
},
responseType: "arraybuffer",
validateStatus: () => true,
};
return client.request(config);
};

View File

@@ -1,22 +1,28 @@
import { Request, Response, NextFunction } from "express"; import { NextFunction, Request, Response } from "express";
import { SERVICE_ID } from "../config/serviceConfig";
// eslint-disable-next-line @typescript-eslint/no-unused-vars export interface ApiError extends Error {
export const errorHandler = ( statusCode?: number;
err: Error, details?: unknown;
req: Request, }
res: Response,
next: NextFunction
): void => {
const status = res.statusCode >= 400 ? res.statusCode : 500;
if (process.env.NODE_ENV !== "test") { /**
console.error(err); * Express error handler that ensures consistent JSON error responses.
} */
export function errorHandler(err: ApiError, req: Request, res: Response, _next: NextFunction) {
const statusCode = err.statusCode || 500;
const requestId = (req as any).requestId;
// TODO: formalize error code taxonomy and mapping from upstream services.
res.status(status).json({ const payload = {
ok: false, error: {
error: err.message || "Internal Server Error", message: statusCode === 500 ? "Internal server error" : err.message,
service: SERVICE_ID, statusCode,
}); requestId,
}; details: statusCode === 500 ? undefined : err.details,
},
};
console.error(`[${requestId || "unknown"}]`, err);
res.status(statusCode).json(payload);
}

View File

@@ -1,28 +1,24 @@
import { Request, Response, NextFunction } from "express"; import { randomUUID } from "crypto";
import { SERVICE_ID } from "../config/serviceConfig"; import { NextFunction, Request, Response } from "express";
import { getApiConfig } from "../config/env";
export const loggingMiddleware = ( /**
req: Request, * Basic request logger that also ensures every request has a request ID.
res: Response, * A structured logger can replace the console usage in the future.
next: NextFunction */
): void => { export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
const start = process.hrtime.bigint(); 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", () => { res.on("finish", () => {
const end = process.hrtime.bigint(); const duration = Date.now() - start;
const durationMs = Number(end - start) / 1_000_000; const message = `${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`;
console.log(`[${requestId}] ${cfg.logLevel.toUpperCase()} ${message}`);
const logEntry = {
ts: new Date().toISOString(),
method: req.method,
path: req.originalUrl || req.url,
status: res.statusCode,
duration_ms: Number(durationMs.toFixed(3)),
service_id: SERVICE_ID,
};
console.log(JSON.stringify(logEntry));
}); });
next(); next();
}; }

View File

@@ -1,24 +0,0 @@
import { Router, Request, Response } from "express";
import {
SERVICE_ID,
OS_ROOT,
CORE_BASE_URL,
OPERATOR_BASE_URL,
} from "../config/serviceConfig";
const router = Router();
router.get("/debug/env", (req: Request, res: Response) => {
res.json({
ok: true,
service: SERVICE_ID,
env: {
NODE_ENV: process.env.NODE_ENV,
OS_ROOT,
CORE_BASE_URL,
OPERATOR_BASE_URL,
},
});
});
export default router;

47
src/routes/finance.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Router } from "express";
import { HttpOperatorClient, OperatorClient } from "../clients/operatorClient";
import { ApiError } from "../middleware/errorHandler";
const PERIOD_REGEX = /^(\d{4}-(0[1-9]|1[0-2])|\d{4}-Q[1-4])$/;
export function createFinanceRouter(operatorClient: OperatorClient = new HttpOperatorClient()): Router {
const router = Router();
router.get("/summary", async (_req, res, next) => {
try {
const summary = await operatorClient.getFinanceSummary();
res.json({ data: summary });
} catch (err) {
next(err);
}
});
router.get("/cash-forecast", async (_req, res, next) => {
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;
}

View File

@@ -1,9 +1,17 @@
import { Router } from "express"; import { Router } from "express";
import { getApiConfig } from "../config/env";
const api = Router(); export function createHealthRouter(): Router {
const router = Router();
api.get("/api/health", (_req, res) => { router.get("/", (_req, res) => {
res.status(200).json({ status: "ok", service: "blackroad-os-api" }); const cfg = getApiConfig();
}); res.json({
status: "ok",
uptimeSeconds: Math.round(process.uptime()),
env: cfg.env,
});
});
export default api; return router;
}

10
src/routes/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Express } from "express";
import { createFinanceRouter } from "./finance";
import { createHealthRouter } from "./health";
import { createVersionRouter } from "./version";
export function registerRoutes(app: Express) {
app.use("/health", createHealthRouter());
app.use("/version", createVersionRouter());
app.use("/finance", createFinanceRouter());
}

View File

@@ -1,23 +0,0 @@
import { Router, Request, Response } from "express";
import packageInfo from "../../package.json";
import {
SERVICE_ID,
SERVICE_NAME,
SERVICE_BASE_URL,
OS_ROOT,
} from "../config/serviceConfig";
const router = Router();
router.get("/info", (req: Request, res: Response) => {
res.json({
name: SERVICE_NAME,
id: SERVICE_ID,
baseUrl: SERVICE_BASE_URL,
version: packageInfo.version,
osRoot: OS_ROOT,
time: new Date().toISOString(),
});
});
export default router;

View File

@@ -1,37 +0,0 @@
import { Router } from "express";
import { AxiosInstance } from "axios";
import { forwardRequest } from "../lib/httpClient";
const mapProxyHeaders = (headers: Record<string, any>) => {
const excluded = new Set([
"transfer-encoding",
"content-encoding",
"content-length",
"connection",
]);
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
if (!excluded.has(key.toLowerCase()) && typeof value === "string") {
acc[key] = value;
}
return acc;
}, {});
};
export const createProxyRouter = (client: AxiosInstance) => {
const router = Router();
router.use(async (req, res, next) => {
try {
const response = await forwardRequest(client, req);
res
.status(response.status)
.set(mapProxyHeaders(response.headers))
.send(response.data);
} catch (error) {
next(error);
}
});
return router;
};

View File

@@ -1,13 +0,0 @@
import { Router, Request, Response } from "express";
import { SERVICE_ID } from "../../config/serviceConfig";
const router = Router();
router.get("/health", (req: Request, res: Response) => {
res.status(200).json({
status: "ok",
service: SERVICE_ID,
});
});
export default router;

View File

@@ -1,15 +0,0 @@
import { Router, Request, Response } from "express";
import { SERVICE_ID, SERVICE_NAME } from "../../config/serviceConfig";
const router = Router();
router.get("/ping", (req: Request, res: Response) => {
res.json({
ok: true,
api: SERVICE_NAME,
service: SERVICE_ID,
ts: new Date().toISOString(),
});
});
export default router;

View File

@@ -1,220 +0,0 @@
import { Request, Response, Router } from "express";
import {
CoreAssessment,
CoreProvenanceGraph,
CoreSnapshot,
CoreTruthState,
CoreVerificationJob,
CoreVerificationError,
VerificationServiceClient,
VerificationJobPayload,
coreVerificationClient,
} from "../../lib/coreVerificationClient";
const sanitizeAgentId = (agentId?: string): string | undefined => {
if (!agentId) return undefined;
const parts = agentId.split(":");
return parts.length > 1 ? parts.slice(-1)[0] : agentId;
};
const mapAssessments = (assessments?: CoreAssessment[]) => {
const items = (assessments || []).map((assessment) => ({
agent_id: sanitizeAgentId(assessment.agent_id),
verdict: assessment.verdict,
confidence: assessment.confidence ?? null,
created_at: assessment.created_at,
evidence_uris: assessment.evidence_uris || [],
}));
return {
count: items.length,
items,
};
};
const mapSnapshot = (snapshot?: CoreSnapshot) => {
if (!snapshot) return null;
return {
id: snapshot.id,
source_uri: snapshot.source_uri,
author_id: snapshot.author_id,
parent_snapshot_id: snapshot.parent_snapshot_id,
created_at: snapshot.created_at,
hash: snapshot.ps_sha_infinity,
};
};
const mapTruthState = (truth?: CoreTruthState | null) => {
if (!truth) return null;
return {
claim_hash: truth.claim_hash,
status: truth.status,
aggregate_confidence: truth.aggregate_confidence ?? null,
job_ids: truth.job_ids || [],
minority_reports: truth.minority_reports || [],
last_updated: truth.last_updated,
policy_id: truth.policy_id,
domain: truth.domain,
};
};
const mapJobSummary = (job: CoreVerificationJob) => ({
id: job.job_id,
snapshot_id: job.snapshot_id || job.snapshot?.id,
claim_hash: job.claim_hash || job.truth_state?.claim_hash || null,
status: job.status,
created_at: job.created_at,
policy_id: job.policy_id,
domain: job.domain,
});
const mapProvenance = (graph: CoreProvenanceGraph) => ({
snapshot: mapSnapshot(graph.snapshot),
parent_snapshot: mapSnapshot(graph.parent_snapshot || undefined),
derived_snapshots: (graph.derived_snapshots || []).map(mapSnapshot),
related_jobs: graph.related_jobs || [],
ledger_entries: graph.ledger_entries || [],
});
const buildValidationError = (message: string, details?: Record<string, any>) => ({
error_code: "INVALID_REQUEST",
message,
details,
});
const handleCoreError = (res: Response, error: unknown) => {
if (error instanceof CoreVerificationError) {
const status = error.status || 502;
const payload =
error.data && typeof error.data === "object"
? error.data
: {
error_code:
status === 404
? "VERIFICATION_RESOURCE_NOT_FOUND"
: "CORE_VERIFICATION_ERROR",
message: error.message,
};
return res.status(status).json(payload);
}
console.error(error);
return res.status(502).json({
error_code: "CORE_VERIFICATION_ERROR",
message: "Core verification service unavailable",
});
};
const parseVerifyRequest = (body: any): VerificationJobPayload | null => {
if (!body || typeof body.text !== "string" || !body.text.trim()) {
return null;
}
const payload: VerificationJobPayload = {
text: body.text,
};
const optionalFields: (keyof VerificationJobPayload)[] = [
"source_uri",
"author_id",
"claim_hash",
"domain",
"policy_id",
"requested_by",
];
optionalFields.forEach((field) => {
const value = body[field];
if (value !== undefined) {
payload[field] = value;
}
});
return payload;
};
export const createVerificationRouter = (
client: VerificationServiceClient = coreVerificationClient
) => {
const router = Router();
router.post("/verify", async (req: Request, res: Response) => {
const payload = parseVerifyRequest(req.body);
if (!payload) {
return res
.status(400)
.json(buildValidationError("`text` is required for verification"));
}
try {
const job = await client.createVerificationJob(payload);
return res.status(202).json({
job: mapJobSummary(job),
snapshot: mapSnapshot(job.snapshot) || null,
truth_state: mapTruthState(job.truth_state),
});
} catch (error) {
return handleCoreError(res, error);
}
});
router.get("/verify/jobs/:jobId", async (req: Request, res: Response) => {
const { jobId } = req.params;
if (!jobId) {
return res
.status(400)
.json(buildValidationError("`job_id` must be provided"));
}
try {
const job = await client.getVerificationJob(jobId);
return res.json({
job: mapJobSummary(job),
snapshot: mapSnapshot(job.snapshot) || null,
assessments: mapAssessments(job.assessments),
truth_state: mapTruthState(job.truth_state),
});
} catch (error) {
return handleCoreError(res, error);
}
});
router.get("/truth/:claimHash", async (req: Request, res: Response) => {
const { claimHash } = req.params;
if (!claimHash) {
return res
.status(400)
.json(buildValidationError("`claim_hash` must be provided"));
}
try {
const truth = await client.getTruthState(claimHash);
return res.json(mapTruthState(truth));
} catch (error) {
return handleCoreError(res, error);
}
});
router.get("/provenance/:snapshotId", async (req: Request, res: Response) => {
const { snapshotId } = req.params;
if (!snapshotId) {
return res
.status(400)
.json(buildValidationError("`snapshot_id` must be provided"));
}
try {
const provenance = await client.getProvenance(snapshotId);
return res.json(mapProvenance(provenance));
} catch (error) {
return handleCoreError(res, error);
}
});
return router;
};
const verificationRouter = createVerificationRouter();
export default verificationRouter;

View File

@@ -1,14 +1,27 @@
import { Router, Request, Response } from "express"; import { Router } from "express";
import packageInfo from "../../package.json"; import packageInfo from "../../package.json";
import { SERVICE_ID } from "../config/serviceConfig"; import { getApiConfig } from "../config/env";
const router = Router(); export function createVersionRouter(): Router {
const router = Router();
router.get("/version", (req: Request, res: Response) => { router.get("/", (_req, res) => {
res.json({ const cfg = getApiConfig();
const payload: Record<string, unknown> = {
version: packageInfo.version, version: packageInfo.version,
service: SERVICE_ID, env: cfg.env,
}); };
});
export default router; 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,7 +1,21 @@
import app from "./index"; import express from "express";
import cors from "cors";
import { loggingMiddleware } from "./middleware/logging";
import { errorHandler } from "./middleware/errorHandler";
import { registerRoutes } from "./routes";
const port = process.env.PORT || 8080; export function createServer() {
const app = express();
app.use(cors());
app.use(express.json());
app.use(loggingMiddleware);
// TODO: add rate limiting/abuse protection middleware when requirements are defined.
app.listen(port, "0.0.0.0", () => { registerRoutes(app);
console.log(`[blackroad-os-api] listening on http://0.0.0.0:${port}`);
}); // TODO: add auth middleware when authN/authZ requirements are defined.
// TODO: emit metrics/traces once observability stack is available.
app.use(errorHandler);
return app;
}

View File

@@ -1,37 +0,0 @@
export type ServiceDescriptor = {
id: string;
name: string;
baseUrl: string;
healthEndpoint: string;
infoEndpoint: string;
};
export const apiService: ServiceDescriptor = {
id: "api",
name: "BlackRoad OS Public API",
baseUrl: process.env.SERVICE_BASE_URL || "https://api.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const coreService: ServiceDescriptor = {
id: "core",
name: "BlackRoad OS Core",
baseUrl: process.env.CORE_BASE_URL || "https://core.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const operatorService: ServiceDescriptor = {
id: "operator",
name: "BlackRoad OS Operator",
baseUrl: process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems",
healthEndpoint: "/health",
infoEndpoint: "/info",
};
export const servicesRegistry = {
api: apiService,
core: coreService,
operator: operatorService,
};

126
src/types/finance.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Snapshot of key cash and revenue metrics produced by finance agents.
* Values follow GAAP concepts but are advisory; see source system for audited numbers.
*/
export interface FinanceSummary {
/** ISO currency code for the represented values. */
currency: string;
/** Current cash balance available. */
cashBalance: number;
/** Average monthly burn, used to calculate runway. */
monthlyBurnRate: number;
/** Estimated runway in months based on current burn. */
runwayMonths: number;
/** Monthly recurring revenue if available. */
mrr?: number;
/** Annual recurring revenue if available. */
arr?: number;
/** ISO timestamp when this summary was generated. */
generatedAt: string;
}
/**
* A bucket of projected cash movement for a date range.
*/
export interface CashForecastBucket {
/** Inclusive start date for the bucket. */
startDate: string;
/** Inclusive end date for the bucket. */
endDate: string;
/** Net change in cash during the period. */
netChange: number;
/** Expected ending balance after applying the net change. */
endingBalance: number;
}
/**
* Cash forecast composed of sequential buckets.
*/
export interface CashForecast {
/** ISO currency code for all buckets. */
currency: string;
/** Ordered list of forecast buckets. */
buckets: CashForecastBucket[];
/** ISO timestamp when the forecast was generated. */
generatedAt: string;
}
/**
* A single line item within a GAAP statement section.
*/
export interface StatementLineItem {
/** Name of the account or classification. */
account: string;
/** Human readable label for the line item. */
label: string;
/** Monetary amount for the item. */
amount: number;
}
/**
* GAAP-inspired income statement.
*/
export interface IncomeStatement {
/** Reporting period identifier (e.g. YYYY-MM or YYYY-Qn). */
period: string;
/** ISO currency code for the statement. */
currency: string;
/** Revenue line items. */
revenue: StatementLineItem[];
/** Cost of goods sold line items. */
cogs: StatementLineItem[];
/** Operating expense line items. */
operatingExpenses: StatementLineItem[];
/** Other income or expense adjustments. */
otherIncomeExpenses: StatementLineItem[];
/** Net income for the period. */
netIncome: number;
}
/**
* GAAP-inspired balance sheet representation.
*/
export interface BalanceSheet {
/** Reporting period identifier. */
period: string;
/** ISO currency code for the statement. */
currency: string;
/** Asset line items. */
assets: StatementLineItem[];
/** Liability line items. */
liabilities: StatementLineItem[];
/** Equity line items. */
equity: StatementLineItem[];
}
/**
* GAAP-inspired cash flow statement representation.
*/
export interface CashFlowStatement {
/** Reporting period identifier. */
period: string;
/** ISO currency code for the statement. */
currency: string;
/** Cash from operating activities. */
operatingActivities: StatementLineItem[];
/** Cash from investing activities. */
investingActivities: StatementLineItem[];
/** Cash from financing activities. */
financingActivities: StatementLineItem[];
/** Net change in cash across all activities. */
netChangeInCash: number;
}
/**
* Collection of GAAP-like statements for a given period.
*/
export interface FinancialStatements {
/** Period identifier matching the source system (YYYY-MM or YYYY-Qn). */
period: string;
/** Income statement for the period. */
incomeStatement: IncomeStatement;
/** Balance sheet for the period. */
balanceSheet: BalanceSheet;
/** Cash flow statement for the period. */
cashFlowStatement: CashFlowStatement;
}

106
tests/finance.test.ts Normal file
View File

@@ -0,0 +1,106 @@
import express from "express";
import request from "supertest";
import { createFinanceRouter } from "../src/routes/finance";
import { errorHandler } from "../src/middleware/errorHandler";
import { OperatorClient } from "../src/clients/operatorClient";
import { CashForecast, FinanceSummary, FinancialStatements } from "../src/types/finance";
describe("finance routes", () => {
let app: express.Express;
let operatorClient: jest.Mocked<OperatorClient>;
const summaryFixture: FinanceSummary = {
currency: "USD",
cashBalance: 100000,
monthlyBurnRate: 20000,
runwayMonths: 5,
mrr: 50000,
arr: 600000,
generatedAt: new Date().toISOString(),
};
const forecastFixture: CashForecast = {
currency: "USD",
generatedAt: new Date().toISOString(),
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,12 +1,14 @@
import request from "supertest"; import request from "supertest";
import app from "../src/index"; import { createServer } from "../src/server";
describe("GET /api/health", () => { const app = createServer();
it("returns ok status and service id", async () => {
const response = await request(app).get("/api/health");
expect(response.status).toBe(200); describe("GET /health", () => {
expect(response.body).toHaveProperty("status", "ok"); it("returns ok with uptime and env", async () => {
expect(response.body).toHaveProperty("service", "blackroad-os-api"); const res = await request(app).get("/health").expect(200);
expect(res.body.status).toBe("ok");
expect(typeof res.body.uptimeSeconds).toBe("number");
expect(res.body.env).toBeDefined();
}); });
}); });

View File

@@ -1,16 +0,0 @@
import request from "supertest";
import app from "../src/index";
import packageInfo from "../package.json";
describe("GET /info", () => {
it("returns service metadata", async () => {
const response = await request(app).get("/info");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: "api",
name: "BlackRoad OS Public API",
version: packageInfo.version,
});
});
});

View File

@@ -1,12 +0,0 @@
import request from "supertest";
import app from "../src/index";
describe("GET /v1/ping", () => {
it("returns ok response", async () => {
const response = await request(app).get("/v1/ping");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("ok", true);
expect(response.body).toHaveProperty("service", "api");
});
});

View File

@@ -1,211 +0,0 @@
import express from "express";
import request from "supertest";
import {
CoreVerificationError,
VerificationServiceClient,
} from "../src/lib/coreVerificationClient";
import { createVerificationRouter } from "../src/routes/v1/verify";
describe("Verification routes", () => {
const app = express();
app.use(express.json());
const mockClient: jest.Mocked<VerificationServiceClient> = {
createVerificationJob: jest.fn(),
getVerificationJob: jest.fn(),
getTruthState: jest.fn(),
getProvenance: jest.fn(),
};
app.use("/v1", createVerificationRouter(mockClient));
beforeEach(() => {
jest.clearAllMocks();
});
describe("POST /v1/verify", () => {
it("creates a verification job", async () => {
mockClient.createVerificationJob.mockResolvedValue({
job_id: "job-123",
snapshot_id: "snap-1",
claim_hash: "claim-xyz",
status: "pending",
created_at: "2024-07-01T00:00:00Z",
policy_id: "policy-1",
domain: "news",
});
const response = await request(app).post("/v1/verify").send({
text: "Example text",
domain: "news",
});
expect(response.status).toBe(202);
expect(response.body).toMatchObject({
job: {
id: "job-123",
snapshot_id: "snap-1",
status: "pending",
domain: "news",
},
truth_state: null,
});
expect(mockClient.createVerificationJob).toHaveBeenCalledWith({
text: "Example text",
domain: "news",
});
});
it("validates missing text", async () => {
const response = await request(app).post("/v1/verify").send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("error_code", "INVALID_REQUEST");
});
});
describe("GET /v1/verify/jobs/:jobId", () => {
it("returns job details", async () => {
mockClient.getVerificationJob.mockResolvedValue({
job_id: "job-123",
snapshot_id: "snap-1",
claim_hash: "claim-xyz",
status: "running",
created_at: "2024-07-01T00:00:00Z",
assessments: [
{
agent_id: "agent:alpha",
verdict: "confirmed",
confidence: 0.92,
created_at: "2024-07-01T01:00:00Z",
evidence_uris: ["https://example.com"],
},
],
truth_state: {
claim_hash: "claim-xyz",
status: "confirmed",
aggregate_confidence: 0.9,
job_ids: ["job-123"],
minority_reports: [],
last_updated: "2024-07-01T02:00:00Z",
policy_id: "policy-1",
domain: "news",
},
snapshot: {
id: "snap-1",
source_uri: "https://example.com/post",
author_id: "author-1",
parent_snapshot_id: null,
created_at: "2024-07-01T00:00:00Z",
ps_sha_infinity: "hash123",
},
});
const response = await request(app).get("/v1/verify/jobs/job-123");
expect(response.status).toBe(200);
expect(response.body.job).toMatchObject({ id: "job-123", status: "running" });
expect(response.body.snapshot).toMatchObject({ id: "snap-1", hash: "hash123" });
expect(response.body.assessments.count).toBe(1);
expect(response.body.truth_state).toMatchObject({ status: "confirmed" });
});
it("maps 404 errors from core", async () => {
mockClient.getVerificationJob.mockRejectedValue(
new CoreVerificationError("Not found", 404, {
error_code: "VERIFICATION_JOB_NOT_FOUND",
message: "Verification job not found",
})
);
const response = await request(app).get("/v1/verify/jobs/unknown");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("error_code", "VERIFICATION_JOB_NOT_FOUND");
});
});
describe("GET /v1/truth/:claimHash", () => {
it("returns truth state", async () => {
mockClient.getTruthState.mockResolvedValue({
claim_hash: "claim-xyz",
status: "confirmed",
aggregate_confidence: 0.8,
job_ids: ["job-1"],
minority_reports: [],
last_updated: "2024-07-01T03:00:00Z",
policy_id: "policy-1",
domain: "news",
});
const response = await request(app).get("/v1/truth/claim-xyz");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
claim_hash: "claim-xyz",
status: "confirmed",
aggregate_confidence: 0.8,
});
});
it("returns 404 for unknown claim", async () => {
mockClient.getTruthState.mockRejectedValue(
new CoreVerificationError("Unknown claim", 404, {
error_code: "TRUTH_STATE_NOT_FOUND",
message: "Truth state not found",
})
);
const response = await request(app).get("/v1/truth/missing-claim");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("error_code", "TRUTH_STATE_NOT_FOUND");
});
});
describe("GET /v1/provenance/:snapshotId", () => {
it("returns provenance graph", async () => {
mockClient.getProvenance.mockResolvedValue({
snapshot: {
id: "snap-1",
source_uri: "https://example.com/post",
author_id: "author-1",
parent_snapshot_id: null,
created_at: "2024-07-01T00:00:00Z",
ps_sha_infinity: "hash123",
},
parent_snapshot: null,
derived_snapshots: [
{
id: "snap-2",
parent_snapshot_id: "snap-1",
created_at: "2024-07-01T02:00:00Z",
},
],
related_jobs: [{ id: "job-1", status: "pending" }],
ledger_entries: [{ id: "ledger-1", type: "snapshot" }],
});
const response = await request(app).get("/v1/provenance/snap-1");
expect(response.status).toBe(200);
expect(response.body.snapshot).toMatchObject({ id: "snap-1" });
expect(response.body.derived_snapshots[0]).toMatchObject({ id: "snap-2" });
expect(response.body.related_jobs[0]).toMatchObject({ id: "job-1" });
});
it("maps missing snapshot to 404", async () => {
mockClient.getProvenance.mockRejectedValue(
new CoreVerificationError("Not found", 404, {
error_code: "SNAPSHOT_NOT_FOUND",
message: "Snapshot not found",
})
);
const response = await request(app).get("/v1/provenance/unknown");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("error_code", "SNAPSHOT_NOT_FOUND");
});
});
});