upload current sources
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user