Files
arena-plm-fiori/worker/src/server.ts
T
2026-09-16 01:47:28 -07:00

212 lines
8.9 KiB
TypeScript

/**
* 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<ArenaSessionManager['state']> };
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<HealthStatus> => {
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);
}