fix(api): close contract gaps found by post-merge adversarial audit

A 15-agent audit of the merged tree confirmed 9 envelope/contract bugs;
all fixed here, with live-server contract tests added:

Blockers (fresh-install quick start broken):
- session-ui.js runClaude/runShell unwrapped .data from the /api/cases/:name
  404 error envelope (which has no data key), so a not-yet-created case threw
  TypeError instead of triggering the auto-create fallback. Now '?.data ?? {}'.

Contract violations on the new stable surface:
- Unknown /api routes returned HTTP 404 with {success:true,...} (Fastify's
  default not-found payload was wrapped by the envelope hook). Added a
  setNotFoundHandler returning the standard error envelope for /api paths.
- POST /api/events/subscribe 400 body became {success:true,data:{error}};
  now createErrorResponse(INVALID_INPUT).
- POST /api/clipboard validation error lacked errorCode and shipped HTTP 200;
  now createErrorResponse(INVALID_INPUT) -> 400.
- POST /api/run catch path returned bare {success:false,sessionId,error}
  (HTTP 200, no errorCode); now OPERATION_FAILED envelope -> 422 with the
  dead session id in the message.
- DELETE tail-file/:streamId returned {success: closed}, colliding with the
  envelope discriminator; now returns {closed}.

Dead/regressed UI paths:
- Plan history modal could never open: route returned the bare history array
  under data while the frontend read data.data.history/currentVersion. Route
  now returns {history, currentVersion}; modal task count fixed to stats.total.
- Self-update error toast read j.error.message from the string-typed envelope
  error, always falling back to the generic message; now reads the string.

Cleanup:
- Removed the stale QuickStartResponse type (unreferenced; documented the
  pre-envelope shape and invited success-key collisions).

Tests: new test/http-contract.test.ts boots a real WebServer (port 3168) and
pins the envelope, /api/v1 alias, error statuses, and the /api 404 shape —
the route-test harness does not install the server-level hook, so these need
the live server. Updated file-routes/plan-routes/scheduled-runs tests to the
fixed shapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 03:41:04 +02:00
co-authored by Claude Opus 4.8
parent 732463b36e
commit c29475ed10
13 changed files with 137 additions and 35 deletions
+14 -1
View File
@@ -104,6 +104,7 @@ function rewriteApiV1Url(url: string): string {
import {
getErrorMessage,
httpStatusForErrorCode,
createErrorResponse,
ApiErrorCode,
type PersistedRespawnConfig,
type NiceConfig,
@@ -738,7 +739,7 @@ export class WebServer extends EventEmitter {
this.app.post('/api/events/subscribe', (req, reply) => {
const body = (req.body || {}) as { clientId?: string; sessions?: string[] | null };
if (typeof body.clientId !== 'string' || !SSE_CLIENT_ID_RE.test(body.clientId)) {
reply.code(400).send({ error: 'clientId required' });
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'clientId required'));
return;
}
const sessions = Array.isArray(body.sessions)
@@ -752,6 +753,18 @@ export class WebServer extends EventEmitter {
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Stable-contract 404 for unknown /api routes — without this, Fastify's
// default not-found payload {message,error,statusCode} would be wrapped by
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
this.app.setNotFoundHandler((req, reply) => {
const notFound = `Route ${req.method}:${req.url} not found`;
if (req.url.startsWith('/api')) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
return;
}
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
});
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
// text/plain is used ONLY by this beacon (navigator.sendBeacon sends text/plain).
// Keep the body as a RAW STRING and parse it inside the handler — a global