feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1

Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 01:30:43 +02:00
co-authored by Claude Opus 4.8
parent 5b3024b327
commit 458fb81cbe
49 changed files with 929 additions and 554 deletions
+32 -25
View File
@@ -93,7 +93,7 @@ describe('SSE Events', () => {
// Parse and check for init event
const events = parseSSEEvents(receivedData);
const initEvent = events.find(e => e.event === 'init');
const initEvent = events.find((e) => e.event === 'init');
expect(initEvent).toBeDefined();
expect((initEvent?.data as any).sessions).toBeDefined();
@@ -109,7 +109,7 @@ describe('SSE Events', () => {
// Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
}).then(async response => {
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
@@ -123,7 +123,7 @@ describe('SSE Events', () => {
});
// Give time to connect
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session
await fetch(`${baseUrl}/api/sessions`, {
@@ -133,15 +133,17 @@ describe('SSE Events', () => {
});
// Wait a bit for the event
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try { await fetchPromise; } catch {}
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const sessionCreated = events.find(e => e.event === 'session:created');
const sessionCreated = events.find((e) => e.event === 'session:created');
expect(sessionCreated).toBeDefined();
expect((sessionCreated?.data as any).id).toBeDefined();
@@ -151,12 +153,13 @@ describe('SSE Events', () => {
describe('GET /api/status', () => {
it('should return full state', async () => {
const response = await fetch(`${baseUrl}/api/status`);
const data = await response.json();
const body = await response.json();
expect(data).toHaveProperty('sessions');
expect(data).toHaveProperty('scheduledRuns');
expect(data).toHaveProperty('respawnStatus');
expect(data).toHaveProperty('timestamp');
expect(body.success).toBe(true);
expect(body.data).toHaveProperty('sessions');
expect(body.data).toHaveProperty('scheduledRuns');
expect(body.data).toHaveProperty('respawnStatus');
expect(body.data).toHaveProperty('timestamp');
});
it('should include active sessions', async () => {
@@ -168,9 +171,9 @@ describe('SSE Events', () => {
});
const response = await fetch(`${baseUrl}/api/status`);
const data = await response.json();
const body = await response.json();
expect(data.sessions.length).toBeGreaterThan(0);
expect(body.data.sessions.length).toBeGreaterThan(0);
});
});
});
@@ -197,7 +200,7 @@ describe('SSE Event Types', () => {
// Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
}).then(async response => {
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
@@ -211,7 +214,7 @@ describe('SSE Event Types', () => {
});
// Give time to connect
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
@@ -222,23 +225,25 @@ describe('SSE Event Types', () => {
const createData = await createRes.json();
// Delete it
await fetch(`${baseUrl}/api/sessions/${createData.session.id}`, {
await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
method: 'DELETE',
});
// Wait for events
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try { await fetchPromise; } catch {}
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const sessionDeleted = events.find(e => e.event === 'session:deleted');
const sessionDeleted = events.find((e) => e.event === 'session:deleted');
expect(sessionDeleted).toBeDefined();
expect((sessionDeleted?.data as any).id).toBe(createData.session.id);
expect((sessionDeleted?.data as any).id).toBe(createData.data.session.id);
});
});
@@ -250,7 +255,7 @@ describe('SSE Event Types', () => {
// Start listening
const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
}).then(async response => {
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
@@ -264,7 +269,7 @@ describe('SSE Event Types', () => {
});
// Give time to connect
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a case
const caseName = `test-sse-case-${Date.now()}`;
@@ -275,15 +280,17 @@ describe('SSE Event Types', () => {
});
// Wait for events
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try { await fetchPromise; } catch {}
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const caseCreated = events.find(e => e.event === 'case:created');
const caseCreated = events.find((e) => e.event === 'case:created');
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);