Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/app/api/ops/validate/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { POST } from './route';

function request() {
return new Request('http://localhost/api/ops/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ capabilityId: 'cap-test', capabilityName: 'Test capability' }),
});
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe('POST /api/ops/validate', () => {
it('fails closed without inventing CAPPO latency when the probe cannot connect', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('connection refused'));
vi.stubGlobal('fetch', fetchMock);

const response = await POST(request());
const body = await response.json();

expect(response.status).toBe(502);
expect(body.error).toBe('CAPPO health probe failed');
expect(body.logs).toContain('[CAPPO] Health probe failed. No latency measurement recorded.');
expect(body.logs.join('\n')).not.toMatch(/latency to cappo-backend/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not anchor evidence when CAPPO returns an unhealthy response', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 503 }));
vi.stubGlobal('fetch', fetchMock);

const response = await POST(request());
const body = await response.json();

expect(response.status).toBe(502);
expect(body.error).toBe('CAPPO health probe unhealthy');
expect(body.cappo_status).toBe(503);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('anchors only after a successful CAPPO health response', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ evidence_hash: 'evidence-test-hash' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);

const response = await POST(request());
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.anchorHash).toBe('evidence-test-hash');
expect(fetchMock).toHaveBeenCalledTimes(2);

const pglRequest = fetchMock.mock.calls[1];
const pglBody = JSON.parse(String(pglRequest[1]?.body));
expect(pglBody.latency_ms).toEqual(expect.any(Number));
});
});
52 changes: 32 additions & 20 deletions src/app/api/ops/validate/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,20 +12,33 @@ export async function POST(request: Request) {
const logs: string[] = [];
logs.push(`[cAPI-ops-router] Received ops command for "${capabilityName}" (${capabilityId})`);

// 1. SLA test packet injection — real fetch to cappo-backend to measure latency
// 1. Probe CAPPO and only report latency when an HTTP response was actually observed.
const start = Date.now();
let cappoRes: Response;
try {
await fetch('http://cappo-backend-node:8002/health', { signal: AbortSignal.timeout(2000) }).catch(() => {});
} catch (_e) {
// Latency probe — ignore connection failure; we only want elapsed time
cappoRes = await fetch('http://cappo-backend-node:8002/health', {
signal: AbortSignal.timeout(2000),
});
} catch (_error) {
logs.push('[CAPPO] Health probe failed. No latency measurement recorded.');
return NextResponse.json(
{ error: 'CAPPO health probe failed', logs },
{ status: 502 },
);
}
const latency = Date.now() - start;
logs.push(`[sub-agent-beta] SLA probe: latency to cappo-backend = ${latency}ms`);

// 2. Commit cryptographic signature to PGL (GnomLedger)
logs.push(`[sub-agent-gamma] Committing capability validation signature to PGL...`);
const latency = Date.now() - start;
if (!cappoRes.ok) {
logs.push(`[CAPPO] Health probe returned status ${cappoRes.status} after ${latency}ms.`);
return NextResponse.json(
{ error: 'CAPPO health probe unhealthy', cappo_status: cappoRes.status, logs },
{ status: 502 },
);
}
logs.push(`[CAPPO] Health probe succeeded in ${latency}ms.`);

let anchorHash: string | null = null;
// 2. Commit cryptographic signature to PGL (GnomLedger).
logs.push('[PGL] Committing capability validation signature...');

const pglBaseUrl = process.env.PGL_BASE_URL ?? 'https://pgl.veklom.com';
const pglRes = await fetch(`${pglBaseUrl}/api/tools/mint_settlement_evidence_tool`, {
Expand All@@ -35,27 +48,27 @@ export async function POST(request: Request) {
capabilityId,
capabilityName,
timestamp: new Date().toISOString(),
latency_ms: latency
latency_ms: latency,
}),
signal: AbortSignal.timeout(3000)
signal: AbortSignal.timeout(3000),
});

if (!pglRes.ok) {
logs.push(`[PGL] Error: returned status ${pglRes.status}. Aborting — no fallback hash.`);
return NextResponse.json(
{ error: 'PGL commitment failed', pgl_status: pglRes.status, logs },
{ status: 502 }
{ status: 502 },
);
}

const pglData = await pglRes.json();
Comment on lines +53 to 64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return 502 for PGL transport and response-decoding failures.

If the PGL fetch call rejects, such as on timeout or DNS failure, the outer handler returns 500. If pglRes.json() rejects, the outer handler also returns 500. Both cases are PGL anchoring failures.

Wrap the PGL request and JSON decoding in a local try/catch. Return the existing controlled 502 response from that handler. Add regression tests for a rejected PGL request and invalid PGL JSON.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/ops/validate/route.ts` around lines 53 - 64, Wrap the PGL fetch
and pglRes.json() operations in the route handler’s local try/catch so
transport, timeout, DNS, and response-decoding failures return the existing
controlled 502 response instead of reaching the outer 500 handler. Preserve the
current non-OK status handling and add regression coverage for rejected PGL
requests and invalid PGL JSON.

anchorHash = pglData.evidence_hash ?? pglData.result?.evidence_hash ?? pglData.response?.evidence_hash ?? null;
const anchorHash = pglData.evidence_hash ?? pglData.result?.evidence_hash ?? pglData.response?.evidence_hash ?? null;

if (!anchorHash) {
logs.push(`[PGL] Error: response OK but no evidence_hash returned. Aborting.`);
logs.push('[PGL] Error: response OK but no evidence_hash returned. Aborting.');
return NextResponse.json(
{ error: 'PGL returned no evidence hash', logs },
{ status: 502 }
{ status: 502 },
);
}

Expand All@@ -64,14 +77,13 @@ export async function POST(request: Request) {
return NextResponse.json({
success: true,
logs,
anchorHash
anchorHash,
});

} catch (error: any) {
} catch (error) {
console.error('Ops Validate Error:', error);
return NextResponse.json(
{ error: 'Internal Server Error', details: error.message },
{ status: 500 }
{ error: 'Internal Server Error' },
{ status: 500 },
);
}
}
Loading