feat: scaffold finance api gateway
This commit is contained in:
126
README.md
126
README.md
@@ -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
28
docs/api-overview.md
Normal 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
230
docs/openapi.yaml
Normal 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'
|
||||||
@@ -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": {
|
||||||
|
|||||||
64
src/clients/operatorClient.ts
Normal file
64
src/clients/operatorClient.ts
Normal 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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
|
||||||
};
|
|
||||||
|
|||||||
41
src/index.ts
41
src/index.ts
@@ -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;
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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
47
src/routes/finance.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
10
src/routes/index.ts
Normal 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());
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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();
|
||||||
version: packageInfo.version,
|
const payload: Record<string, unknown> = {
|
||||||
service: SERVICE_ID,
|
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);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
return router;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
126
src/types/finance.ts
Normal 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
106
tests/finance.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user