/** * HTTP surface of the connectivity worker — machine-to-machine only, no UI, * no user-facing URLs (ADR-002). Endpoint shapes follow the governed contract * docs/api/worker.openapi.json (API-first, CLAUDE.md rule 7); conformance is * reviewed at that seam. */ import { STATUS_CODES } from 'node:http'; import Fastify, { type FastifyInstance } from 'fastify'; import { makeBearerAuthHook } from './auth/bearerAuth.js'; import type { WorkerConfig } from './config.js'; import { ArenaApiError, type ArenaSessionManager } from './arena/session.js'; import type { HealthRegistry, PollerHealth } from './health/registry.js'; import { sendProblem } from './http/problem.js'; import type { InMemoryEventQueue } from './queue/webhookQueue.js'; import type { RunStore } from './sync/runStore.js'; import type { SyncRunner } from './sync/runner.js'; import { SYNC_OBJECT_TYPES, SYNC_SCOPES, type SyncObjectType, type SyncRun, type SyncScope, } from './sync/types.js'; import { WORKER_VERSION } from './version.js'; /** Contract: `Retry-After` delay-seconds sent with a 503 from the webhook intake. */ export const WEBHOOK_RETRY_AFTER_SECONDS = 30; export interface ServerDeps { config: WorkerConfig; queue: InMemoryEventQueue; runner: SyncRunner; runStore: RunStore; arenaSession: ArenaSessionManager; health: HealthRegistry; /** Enable request logging (off by default so tests stay quiet). */ logger?: boolean; /** * Max request body in bytes. Unset => Fastify's default 1 MiB (1048576); * over the limit the framework answers 413 before the handler runs * (contract: `PayloadTooLarge`). Exposed so the conformance suite can * exercise 413 with a small limit. * [source: context7 /fastify/fastify docs/Reference/Server.md `bodyLimit`, verified 2026-09-14] */ bodyLimit?: number; } /** Wire shape of GET /health (contract `HealthStatus`). */ interface HealthStatus { status: 'ok' | 'degraded'; version: string; uptimeSeconds: number; arenaSession: { state: ReturnType }; poller?: PollerHealth; } export function buildServer(deps: ServerDeps): FastifyInstance { const { config, queue, runner, runStore, arenaSession, health } = deps; const app = Fastify({ logger: deps.logger ?? false, // Seam ruling (2026-09-10): STRICT VALIDATION — the contract's // `additionalProperties: false` on SyncRunRequest must mean 400, and a // body is validated exactly as sent. Fastify's BASELINE Ajv config is // { coerceTypes: 'array', useDefaults: true, removeAdditional: true } // (Ajv's own defaults are all `false`), which would (a) silently STRIP an // unknown property such as a typo'd `object`, (b) unwrap `["full"]` to // `"full"` and stringify scalars, and (c) inject schema defaults. On a // machine-to-machine admin API each of those hides a caller bug, so all // three are turned off here. `allErrors` stays at Fastify's `false` // (documented DoS surface when true). // [source: context7 /fastify/fastify docs/Reference/Validation-and-Serialization.md "Fastify Baseline Ajv Configuration"; docs/Reference/Server.md "ajv customOptions", verified 2026-09-14] // [source: context7 /ajv-validator/ajv docs/options.md "removeAdditional / useDefaults / coerceTypes" defaults, verified 2026-09-14] ajv: { customOptions: { removeAdditional: false, coerceTypes: false, useDefaults: false } }, ...(deps.bodyLimit !== undefined ? { bodyLimit: deps.bodyLimit } : {}), }); // JSON-only surface. Fastify natively parses `application/json` AND // `text/plain`; removing the latter makes any non-JSON media type (and a // body without a Content-Type) a 415 before the handler runs, instead of a // text body reaching schema validation as a string. Contract: // `UnsupportedMediaType` (415) on both POST operations. // [source: context7 /fastify/fastify docs/Reference/ContentTypeParser.md "removeContentTypeParser"; docs/Reference/Errors.md FST_ERR_CTP_INVALID_MEDIA_TYPE, verified 2026-09-14] app.removeContentTypeParser('text/plain'); // Auth runs in `onRequest` (before body parsing/validation) so an // unauthenticated request is always 401, never 400. const requireBearer = makeBearerAuthHook(config.bearerToken); // Contract: ALL error bodies are RFC 9457 application/problem+json. // Covers schema-validation 400s (error.validation), body-parse 400s, // framework-raised 413 (over bodyLimit) / 415 (unsupported media type), // upstream Arena failures (502, body never forwarded), and anything else // Fastify surfaces (500, the contract's `default`). 5xx details are not // leaked; 4xx `detail` is the framework's or Ajv's own fixed message. // [source: context7 /fastify/fastify docs/Reference/Errors.md FST_ERR_CTP_BODY_TOO_LARGE (413), FST_ERR_CTP_INVALID_MEDIA_TYPE (415), verified 2026-09-14] app.setErrorHandler((err: unknown, req, reply) => { if (err instanceof ArenaApiError) { // Upstream failure, not ours. Log the status only — Arena error bodies // can echo request data — and answer with the fixed contract phrase. req.log.error({ upstreamStatus: err.status, message: err.message }, 'upstream Arena request failed'); return sendProblem(reply, 502, 'Bad Gateway', 'Upstream Arena PLM request failed.'); } const statusCode = typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode: unknown }).statusCode === 'number' ? (err as { statusCode: number }).statusCode : undefined; const status = statusCode !== undefined && statusCode >= 400 ? statusCode : 500; const title = STATUS_CODES[status] ?? 'Error'; if (status >= 500) { req.log.error({ err }, 'unhandled error'); return sendProblem(reply, status, title); } const detail = err instanceof Error ? err.message : String(err); return sendProblem(reply, status, title, detail); }); // Unmatched path OR unmatched method on a known path: Fastify's router // routes both to the not-found handler (404, never 405 — probed 2026-09-14). // Contract: `x-unmatched-routes` / every operation's `default` response. app.setNotFoundHandler((req, reply) => sendProblem(reply, 404, 'Not Found', `Route ${req.method} ${req.url} not found`), ); app.get('/health', async (): Promise => { const body: HealthStatus = { status: health.status(), version: WORKER_VERSION, uptimeSeconds: Math.round(process.uptime()), arenaSession: { state: arenaSession.state() }, }; const poller = health.pollerSnapshot(); if (poller !== undefined) body.poller = poller; return body; }); app.post( '/webhooks/arena', { onRequest: requireBearer, schema: { // Any JSON object is accepted as-is; payloads stay raw until the // ingest pipeline interprets them. Non-objects and invalid JSON => 400. body: { type: 'object' }, }, }, async (req, reply) => { const result = queue.enqueue({ source: 'webhook', payload: req.body }); if (!result.accepted) { // Bounded buffer full: backpressure to the sender, never a spill to // worker-local storage (ARN-003). return sendProblem(reply, 503, 'Service Unavailable', 'Ingest queue is full; retry later.', { 'retry-after': String(WEBHOOK_RETRY_AFTER_SECONDS), }); } return reply.code(202).send({ received: true }); }, ); app.post<{ Body: { scope: SyncScope; objects?: SyncObjectType[] } }>( '/admin/sync-runs', { onRequest: requireBearer, schema: { body: { type: 'object', additionalProperties: false, required: ['scope'], properties: { scope: { type: 'string', enum: [...SYNC_SCOPES] }, // Optional: selects AND orders the object types for this run. objects: { type: 'array', minItems: 1, uniqueItems: true, items: { type: 'string', enum: [...SYNC_OBJECT_TYPES] }, }, }, }, }, }, async (req, reply) => { // Request `objects` wins; otherwise the config-driven default (ARN-001). const objectOrder: readonly SyncObjectType[] = req.body.objects ?? config.syncObjectOrder; const run = runner.submit(req.body.scope, objectOrder); return reply.code(201).send(snapshotRun(run)); }, ); app.get<{ Params: { runId: string } }>( '/admin/sync-runs/:runId', { onRequest: requireBearer }, async (req, reply) => { const run = runStore.get(req.params.runId); if (run === undefined) { return sendProblem(reply, 404, 'Not Found', `No sync run with id ${req.params.runId}`); } return reply.code(200).send(snapshotRun(run)); }, ); return app; } /** Deep copy so async run-state mutation cannot race response serialization. */ function snapshotRun(run: SyncRun): SyncRun { return structuredClone(run); }