Add verification API routes and core client

This commit is contained in:
Alexa Amundson
2025-11-22 04:17:40 -06:00
parent d991ff978a
commit 2bac19128c
7 changed files with 605 additions and 1 deletions

4
.gitignore vendored
View File

@@ -211,3 +211,7 @@ node_modules/
dist/ dist/
coverage/ coverage/
npm-debug.log* npm-debug.log*
# Allow TypeScript library utilities under src/lib
!src/lib/
!src/lib/**

View File

@@ -7,10 +7,14 @@ const parsePort = (value: string | undefined, fallback: number): number => {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
}; };
const defaultCoreBaseUrl = process.env.CORE_BASE_URL || "http://localhost:3001";
export const env = { export const env = {
PORT: parsePort(process.env.PORT, 8080), PORT: parsePort(process.env.PORT, 8080),
HOST: process.env.HOST || "0.0.0.0", HOST: process.env.HOST || "0.0.0.0",
CORE_BASE_URL: process.env.CORE_BASE_URL || "http://localhost:3001", CORE_BASE_URL: defaultCoreBaseUrl,
CORE_VERIFICATION_BASE_URL:
process.env.CORE_VERIFICATION_BASE_URL || `${defaultCoreBaseUrl}/internal`,
AGENTS_BASE_URL: process.env.AGENTS_BASE_URL || "http://localhost:3002", AGENTS_BASE_URL: process.env.AGENTS_BASE_URL || "http://localhost:3002",
OPERATOR_BASE_URL: process.env.OPERATOR_BASE_URL || "http://localhost:3003", OPERATOR_BASE_URL: process.env.OPERATOR_BASE_URL || "http://localhost:3003",
SERVICE_VERSION: SERVICE_VERSION:

View File

@@ -6,6 +6,8 @@ export const OS_ROOT = process.env.OS_ROOT || "https://blackroad.systems";
export const CORE_BASE_URL = export const CORE_BASE_URL =
process.env.CORE_BASE_URL || "https://core.blackroad.systems"; process.env.CORE_BASE_URL || "https://core.blackroad.systems";
export const CORE_VERIFICATION_BASE_URL =
process.env.CORE_VERIFICATION_BASE_URL || `${CORE_BASE_URL}/internal`;
export const OPERATOR_BASE_URL = export const OPERATOR_BASE_URL =
process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems"; process.env.OPERATOR_BASE_URL || "https://operator.blackroad.systems";
@@ -15,5 +17,6 @@ export const serviceConfig = {
SERVICE_BASE_URL, SERVICE_BASE_URL,
OS_ROOT, OS_ROOT,
CORE_BASE_URL, CORE_BASE_URL,
CORE_VERIFICATION_BASE_URL,
OPERATOR_BASE_URL, OPERATOR_BASE_URL,
}; };

View File

@@ -7,6 +7,7 @@ import infoRouter from "./routes/info";
import versionRouter from "./routes/version"; import versionRouter from "./routes/version";
import pingRouter from "./routes/v1/ping"; import pingRouter from "./routes/v1/ping";
import v1HealthRouter from "./routes/v1/health"; import v1HealthRouter from "./routes/v1/health";
import verificationRouter from "./routes/v1/verify";
const app = express(); const app = express();
@@ -18,6 +19,7 @@ app.use(infoRouter);
app.use(versionRouter); app.use(versionRouter);
app.use("/v1", pingRouter); app.use("/v1", pingRouter);
app.use("/v1", v1HealthRouter); app.use("/v1", v1HealthRouter);
app.use("/v1", verificationRouter);
// Proxy routes // Proxy routes
app.use("/core", createProxyRouter(serviceClients.core)); app.use("/core", createProxyRouter(serviceClients.core));

View File

@@ -0,0 +1,160 @@
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();

220
src/routes/v1/verify.ts Normal file
View File

@@ -0,0 +1,220 @@
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;

211
tests/verify.test.ts Normal file
View File

@@ -0,0 +1,211 @@
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");
});
});
});