@sensigo/realm-cli 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ import { type Server, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { Command } from 'commander';
3
+ import type { WorkflowDefinition, WebhookTrigger, TriggerFilter, RunStore, WorkflowRegistrar } from '@sensigo/realm';
4
+ import { type DedupStore } from '../lib/dedup-store.js';
5
+ export interface Logger {
6
+ debug(msg: string, data?: unknown): void;
7
+ info(msg: string, data?: unknown): void;
8
+ warn(msg: string, data?: unknown): void;
9
+ error(msg: string, data?: unknown): void;
10
+ }
11
+ /** Result of an agent spawn — a pid on success, or an error to record as spawn_failed. */
12
+ export type SpawnResult = {
13
+ pid: number;
14
+ } | {
15
+ error: Error;
16
+ };
17
+ /** Injected I/O dependencies — everything that touches disk, the clock, or child processes. */
18
+ export interface ListenDeps {
19
+ workflowStore: Pick<WorkflowRegistrar, 'register'>;
20
+ runStore: Pick<RunStore, 'create' | 'get' | 'update'>;
21
+ dedupStoreFor: (workflowId: string) => DedupStore;
22
+ spawnAgent: (runId: string, cwd: string) => SpawnResult;
23
+ clock: () => number;
24
+ logger: Logger;
25
+ }
26
+ /** A mounted workflow: its definition, trigger, source directory, and pre-resolved secret. */
27
+ export interface WorkflowEntry {
28
+ path: string;
29
+ definition: WorkflowDefinition;
30
+ trigger: WebhookTrigger;
31
+ workflowDir: string;
32
+ /** Resolved from env[auth.secret_from] at startup. undefined only for mode 'none'. */
33
+ secret?: string;
34
+ }
35
+ export interface HandlerOptions {
36
+ maxConcurrent: number;
37
+ maxBodyBytes: number;
38
+ bodyTimeoutMs: number;
39
+ }
40
+ /**
41
+ * Normalises raw Node request headers into a Record<string,string> with lowercased keys and a null
42
+ * prototype (prototype-pollution safe). Duplicated headers (array values) are DROPPED — a duplicated
43
+ * security-relevant header (e.g. the shared_secret header) then resolves to undefined and fails
44
+ * verification, which is the intended fail-closed behaviour (never join a security header).
45
+ */
46
+ export declare function normalizeHeaders(raw: IncomingMessage['headers']): Record<string, string>;
47
+ /** Evaluates filter.all (AND). Header conditions read the normalized header map; path conditions
48
+ * resolve a dot-path against { headers, body }. Any failing condition → false (request ignored). */
49
+ export declare function evaluateFilter(filter: TriggerFilter, headers: Record<string, string>, body: unknown): boolean;
50
+ /**
51
+ * Builds the request handler. The handler runs the linear, fail-closed pipeline for each request.
52
+ * All I/O is injected via `deps`; no global state except the in-flight concurrency counter.
53
+ */
54
+ export declare function makeListenHandler(routes: Map<string, WorkflowEntry>, deps: ListenDeps, options?: Partial<HandlerOptions>): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
55
+ /**
56
+ * Builds the path→workflow route table from loaded workflows. Fail-closed at startup:
57
+ * - workflows without a `trigger:` block are skipped (logged);
58
+ * - a path collision across workflows throws;
59
+ * - every referenced secret env var is resolved now (missing → throws);
60
+ * - `auth.mode === 'none'` emits a prominent warning.
61
+ */
62
+ export declare function buildRouteTable(inputs: Array<{
63
+ definition: WorkflowDefinition;
64
+ workflowDir: string;
65
+ }>, deps: {
66
+ env: Record<string, string | undefined>;
67
+ logger: Logger;
68
+ }): Map<string, WorkflowEntry>;
69
+ export interface ListenOptions {
70
+ port: number;
71
+ host: string;
72
+ bodyTimeoutMs: number;
73
+ maxBodyBytes: number;
74
+ maxConcurrent: number;
75
+ }
76
+ export interface ListenHandle {
77
+ server: Server;
78
+ shutdown(reason: string): Promise<void>;
79
+ }
80
+ /** Wires the HTTP server + SIGTERM/SIGINT handlers. Returns a handle whose shutdown() stops
81
+ * accepting new connections; in-flight requests finish and the detached agents survive. */
82
+ export declare function startListen(routes: Map<string, WorkflowEntry>, options: ListenOptions, deps: ListenDeps): Promise<ListenHandle>;
83
+ /**
84
+ * `realm listen` — start a webhook server that turns verified inbound webhooks into Realm runs.
85
+ *
86
+ * Serves plaintext HTTP. TLS termination, rate limiting, and autoscaling are reverse-proxy /
87
+ * serverless concerns and are intentionally NOT handled here — front this with nginx/Caddy/Traefik
88
+ * for public endpoints. The built-in floors (loopback bind, body cap/timeout, --max-concurrent) are
89
+ * the only DoS protections independent of proxy hygiene.
90
+ */
91
+ export declare const listenCommand: Command;
92
+ //# sourceMappingURL=listen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listen.d.ts","sourceRoot":"","sources":["../../src/commands/listen.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,558 @@
1
+ // listen.ts — `realm listen` server.
2
+ //
3
+ // A thin, fail-closed webhook server: it loads workflows that declare a `trigger:` block, builds a
4
+ // path→workflow route table, and turns each verified inbound webhook into a Realm run + a detached
5
+ // `realm agent --run-id` child. Verification is per the workflow's `trigger.auth` (shared_secret for
6
+ // Gorgias-style header tokens; github/stripe/hmac body signatures; `none` escape hatch).
7
+ //
8
+ // Hardening kept deliberately small (the rest is a reverse-proxy concern, documented in --help):
9
+ // - loopback bind by default; non-loopback host → startup TLS warning
10
+ // - body cap + timeout enforced BEFORE any verification work (fixes the legacy webhook DoS)
11
+ // - --max-concurrent 503 floor (the only fail-closed DoS floor independent of proxy hygiene)
12
+ // - SIGTERM/SIGINT → server.close(); the agent child is detached and survives.
13
+ import { createServer } from 'node:http';
14
+ import { spawn as nodeSpawn } from 'node:child_process';
15
+ import { join } from 'node:path';
16
+ import { Command } from 'commander';
17
+ import { loadWorkflowFromFile, JsonFileStore, JsonWorkflowStore, validateInputSchema, WorkflowError, } from '@sensigo/realm';
18
+ import { verifyGithub, verifyStripe, verifyHmac, verifySharedSecret, } from '../lib/webhook-verifiers.js';
19
+ import { extractParams, extractDedupId, resolveDotPath } from '../lib/webhook-params.js';
20
+ import { FileDedupStore, InMemoryDedupStore } from '../lib/dedup-store.js';
21
+ const DEFAULT_TTL_MINUTES = 60;
22
+ // Fixed signature-header names for the body-signature presets.
23
+ const GITHUB_SIG_HEADER = 'x-hub-signature-256';
24
+ const STRIPE_SIG_HEADER = 'stripe-signature';
25
+ const DEFAULT_HANDLER_OPTIONS = {
26
+ maxConcurrent: 20,
27
+ maxBodyBytes: 1_048_576,
28
+ bodyTimeoutMs: 5000,
29
+ };
30
+ /**
31
+ * Normalises raw Node request headers into a Record<string,string> with lowercased keys and a null
32
+ * prototype (prototype-pollution safe). Duplicated headers (array values) are DROPPED — a duplicated
33
+ * security-relevant header (e.g. the shared_secret header) then resolves to undefined and fails
34
+ * verification, which is the intended fail-closed behaviour (never join a security header).
35
+ */
36
+ export function normalizeHeaders(raw) {
37
+ const out = Object.create(null);
38
+ for (const key of Object.keys(raw)) {
39
+ const value = raw[key];
40
+ if (typeof value === 'string') {
41
+ out[key.toLowerCase()] = value;
42
+ }
43
+ // array (duplicated header) and undefined are intentionally omitted → treated as absent.
44
+ }
45
+ return out;
46
+ }
47
+ /** Returns true when a resolved scalar matches the expected string / string[]. Arrays/objects → false. */
48
+ function matchFilterValue(actual, expected) {
49
+ if (actual === null || actual === undefined)
50
+ return false;
51
+ if (typeof actual === 'object')
52
+ return false; // arrays/objects never match a scalar allow-list
53
+ const s = String(actual);
54
+ return Array.isArray(expected) ? expected.includes(s) : s === expected;
55
+ }
56
+ /** Evaluates filter.all (AND). Header conditions read the normalized header map; path conditions
57
+ * resolve a dot-path against { headers, body }. Any failing condition → false (request ignored). */
58
+ export function evaluateFilter(filter, headers, body) {
59
+ for (const cond of filter.all) {
60
+ const actual = 'header' in cond
61
+ ? headers[cond.header.toLowerCase()]
62
+ : resolveDotPath({ headers, body }, cond.path);
63
+ if (!matchFilterValue(actual, cond.value)) {
64
+ return false;
65
+ }
66
+ }
67
+ return true;
68
+ }
69
+ function verifyRequest(entry, rawBody, headers) {
70
+ const auth = entry.trigger.auth;
71
+ const secret = entry.secret ?? '';
72
+ switch (auth.mode) {
73
+ case 'none':
74
+ return true;
75
+ case 'shared_secret':
76
+ return verifySharedSecret(headers, auth.header, secret);
77
+ case 'github':
78
+ return verifyGithub(rawBody, headers[GITHUB_SIG_HEADER] ?? '', secret);
79
+ case 'stripe':
80
+ return verifyStripe(rawBody, headers[STRIPE_SIG_HEADER] ?? '', secret, auth.max_age_seconds);
81
+ case 'hmac':
82
+ return verifyHmac(rawBody, headers[auth.header.toLowerCase()] ?? '', secret, {
83
+ ...(auth.algorithm !== undefined ? { algorithm: auth.algorithm } : {}),
84
+ ...(auth.encoding !== undefined ? { encoding: auth.encoding } : {}),
85
+ ...(auth.timestamp_header !== undefined ? { timestamp_header: auth.timestamp_header } : {}),
86
+ ...(auth.max_age_seconds !== undefined ? { max_age_seconds: auth.max_age_seconds } : {}),
87
+ headers,
88
+ });
89
+ }
90
+ }
91
+ /** The header a given auth mode reads — used to reject a duplicated (array) security header. */
92
+ function authHeaderName(trigger) {
93
+ switch (trigger.auth.mode) {
94
+ case 'shared_secret':
95
+ case 'hmac':
96
+ return trigger.auth.header.toLowerCase();
97
+ case 'github':
98
+ return GITHUB_SIG_HEADER;
99
+ case 'stripe':
100
+ return STRIPE_SIG_HEADER;
101
+ case 'none':
102
+ return undefined;
103
+ }
104
+ }
105
+ /** Reads the request body with a byte cap and a timeout, before any verification work. */
106
+ function readBody(req, maxBytes, timeoutMs) {
107
+ return new Promise((resolve) => {
108
+ const chunks = [];
109
+ let size = 0;
110
+ let settled = false;
111
+ const finish = (result) => {
112
+ if (settled)
113
+ return;
114
+ settled = true;
115
+ clearTimeout(timer);
116
+ resolve(result);
117
+ };
118
+ const timer = setTimeout(() => {
119
+ finish({ error: 'timeout' });
120
+ req.destroy();
121
+ }, timeoutMs);
122
+ req.on('data', (chunk) => {
123
+ size += chunk.length;
124
+ if (size > maxBytes) {
125
+ finish({ error: 'too_large' });
126
+ req.destroy();
127
+ return;
128
+ }
129
+ chunks.push(chunk);
130
+ });
131
+ req.on('end', () => finish({ body: Buffer.concat(chunks) }));
132
+ req.on('error', () => finish({ error: 'stream' }));
133
+ });
134
+ }
135
+ function respond(res, status, payload) {
136
+ res.writeHead(status, { 'Content-Type': 'application/json' });
137
+ res.end(JSON.stringify(payload));
138
+ }
139
+ /**
140
+ * Builds the request handler. The handler runs the linear, fail-closed pipeline for each request.
141
+ * All I/O is injected via `deps`; no global state except the in-flight concurrency counter.
142
+ */
143
+ export function makeListenHandler(routes, deps, options = {}) {
144
+ const opts = { ...DEFAULT_HANDLER_OPTIONS, ...options };
145
+ let inFlight = 0;
146
+ return async function handle(req, res) {
147
+ // 1. Non-POST → 405.
148
+ if (req.method !== 'POST') {
149
+ respond(res, 405, { error: 'method_not_allowed' });
150
+ return;
151
+ }
152
+ // 2. Path lookup → miss → 403 (never 404 — no path enumeration).
153
+ const path = (() => {
154
+ try {
155
+ return new URL(req.url ?? '/', 'http://localhost').pathname;
156
+ }
157
+ catch {
158
+ return req.url ?? '/';
159
+ }
160
+ })();
161
+ const entry = routes.get(path);
162
+ if (entry === undefined) {
163
+ respond(res, 403, { error: 'forbidden' });
164
+ return;
165
+ }
166
+ // 3. Concurrency floor → 503.
167
+ if (inFlight >= opts.maxConcurrent) {
168
+ respond(res, 503, { error: 'busy', status: 'rejected' });
169
+ return;
170
+ }
171
+ inFlight += 1;
172
+ try {
173
+ // 4. Read body with cap (413) + timeout (408) BEFORE any verification.
174
+ const bodyResult = await readBody(req, opts.maxBodyBytes, opts.bodyTimeoutMs);
175
+ if ('error' in bodyResult) {
176
+ if (bodyResult.error === 'too_large') {
177
+ respond(res, 413, { error: 'payload_too_large' });
178
+ }
179
+ else if (bodyResult.error === 'timeout') {
180
+ respond(res, 408, { error: 'request_timeout' });
181
+ }
182
+ else {
183
+ respond(res, 400, { error: 'bad_request' });
184
+ }
185
+ return;
186
+ }
187
+ const rawBody = bodyResult.body;
188
+ const headers = normalizeHeaders(req.headers);
189
+ // 5. Content-Type must be application/json (media type is case-insensitive per RFC 7231).
190
+ const contentType = (headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase();
191
+ if (contentType !== 'application/json') {
192
+ respond(res, 415, { error: 'unsupported_media_type' });
193
+ return;
194
+ }
195
+ // A duplicated (array) security header was dropped by normalizeHeaders → reject explicitly.
196
+ const secHeader = authHeaderName(entry.trigger);
197
+ if (secHeader !== undefined && Array.isArray(req.headers[secHeader])) {
198
+ deps.logger.warn('webhook: duplicated security header', { path, header: secHeader });
199
+ respond(res, 403, { error: 'forbidden' });
200
+ return;
201
+ }
202
+ // 6. Verify per auth.mode → 403 on failure.
203
+ if (!verifyRequest(entry, rawBody, headers)) {
204
+ deps.logger.warn('webhook: verification failed', { path, mode: entry.trigger.auth.mode });
205
+ respond(res, 403, { error: 'forbidden' });
206
+ return;
207
+ }
208
+ // Parse JSON body (needed for filter / dedup / params).
209
+ let body;
210
+ try {
211
+ body = JSON.parse(rawBody.toString('utf-8'));
212
+ }
213
+ catch {
214
+ respond(res, 400, { error: 'invalid_json' });
215
+ return;
216
+ }
217
+ // 7. Filter (AND). Any condition fails → 200 ignored.
218
+ if (entry.trigger.filter !== undefined &&
219
+ !evaluateFilter(entry.trigger.filter, headers, body)) {
220
+ deps.logger.debug('webhook: filtered out', { path });
221
+ respond(res, 200, { status: 'ignored' });
222
+ return;
223
+ }
224
+ // 8. Dedup (default on unless dedup === false).
225
+ // TOCTOU note: the window between check() here and record() after a successful spawn is
226
+ // intentional at-least-once semantics — two near-simultaneous duplicate deliveries can both
227
+ // pass check() and create runs. The run store's idempotencyKey (passed at create, = dedupId)
228
+ // is the cross-restart backstop; the in-flight dedup store is the primary, best-effort guard.
229
+ const dedup = entry.trigger.dedup;
230
+ let dedupId;
231
+ let ttlMs = 0;
232
+ // Resolved once and reused for both check() and record() so correctness does not depend on
233
+ // dedupStoreFor caching identical stores across calls.
234
+ let dedupStore;
235
+ if (dedup !== undefined && dedup !== false) {
236
+ const dedupConfig = dedup;
237
+ ttlMs = (dedupConfig.ttl_minutes ?? DEFAULT_TTL_MINUTES) * 60_000;
238
+ dedupId = extractDedupId({ headers, body }, dedupConfig);
239
+ if (dedupId === undefined) {
240
+ if ((dedupConfig.on_missing_id ?? 'skip') === 'reject') {
241
+ respond(res, 400, { error: 'dedup_id_unresolvable', status: 'rejected' });
242
+ return;
243
+ }
244
+ deps.logger.warn('webhook: dedup id unresolvable, proceeding without dedup', { path });
245
+ }
246
+ else {
247
+ dedupStore = deps.dedupStoreFor(entry.definition.id);
248
+ if (dedupStore.check(dedupId, ttlMs)) {
249
+ deps.logger.debug('webhook: deduplicated', { path });
250
+ respond(res, 200, { status: 'deduplicated' });
251
+ return;
252
+ }
253
+ }
254
+ }
255
+ // 9. Params: extract + validate against params_schema.
256
+ const params = entry.trigger.params_map
257
+ ? extractParams({ headers, body }, entry.trigger.params_map, deps.logger)
258
+ : {};
259
+ if (entry.definition.params_schema !== undefined) {
260
+ try {
261
+ validateInputSchema(params, entry.definition.params_schema, entry.definition.id);
262
+ }
263
+ catch (err) {
264
+ const message = err instanceof WorkflowError ? err.message : 'invalid params';
265
+ respond(res, 400, { error: 'params_invalid', message, status: 'rejected' });
266
+ return;
267
+ }
268
+ }
269
+ // 10. Create run via Realm's own infra. dedupId doubles as the run store's idempotency backstop.
270
+ await deps.workflowStore.register(entry.definition);
271
+ let run;
272
+ try {
273
+ run = await deps.runStore.create({
274
+ workflowId: entry.definition.id,
275
+ workflowVersion: entry.definition.version,
276
+ params,
277
+ ...(dedupId !== undefined ? { idempotencyKey: dedupId } : {}),
278
+ });
279
+ }
280
+ catch (err) {
281
+ deps.logger.error('webhook: run creation failed', { path, error: String(err) });
282
+ respond(res, 500, { error: 'store_error', status: 'failed' });
283
+ return;
284
+ }
285
+ // 11. Spawn detached agent.
286
+ // A thrown spawnAgent (contract violation) is funnelled into the same spawn_failed handling
287
+ // below — so the created run is always marked and the response always carries a run_id,
288
+ // never a generic 500 that strands the run unmarked.
289
+ let spawnResult;
290
+ try {
291
+ spawnResult = deps.spawnAgent(run.id, entry.workflowDir);
292
+ }
293
+ catch (err) {
294
+ spawnResult = { error: err instanceof Error ? err : new Error(String(err)) };
295
+ }
296
+ if ('error' in spawnResult) {
297
+ deps.logger.error('webhook: spawn failed', {
298
+ path,
299
+ run_id: run.id,
300
+ error: String(spawnResult.error),
301
+ });
302
+ try {
303
+ run.terminal_state = true;
304
+ run.terminal_reason = 'spawn_failed';
305
+ run.run_phase = 'failed';
306
+ await deps.runStore.update(run);
307
+ }
308
+ catch (err) {
309
+ deps.logger.error('webhook: failed to mark spawn_failed', {
310
+ run_id: run.id,
311
+ error: String(err),
312
+ });
313
+ }
314
+ respond(res, 500, { error: 'spawn_failed', run_id: run.id, status: 'failed' });
315
+ return;
316
+ }
317
+ // Record agent pid/started_at (non-critical metadata), then the dedup record.
318
+ try {
319
+ run.agent_pid = spawnResult.pid;
320
+ run.agent_started_at = new Date(deps.clock()).toISOString();
321
+ await deps.runStore.update(run);
322
+ }
323
+ catch (err) {
324
+ deps.logger.error('webhook: failed to record agent pid', {
325
+ run_id: run.id,
326
+ error: String(err),
327
+ });
328
+ }
329
+ if (dedupId !== undefined && dedupStore !== undefined) {
330
+ try {
331
+ dedupStore.record(dedupId, ttlMs);
332
+ }
333
+ catch (err) {
334
+ deps.logger.error('webhook: failed to record dedup id', {
335
+ run_id: run.id,
336
+ error: String(err),
337
+ });
338
+ }
339
+ }
340
+ deps.logger.info('webhook: dispatched', { path, run_id: run.id, pid: spawnResult.pid });
341
+ // 12. Accepted.
342
+ respond(res, 202, { run_id: run.id, status: 'accepted' });
343
+ }
344
+ finally {
345
+ inFlight -= 1;
346
+ }
347
+ };
348
+ }
349
+ function isLoopbackHost(host) {
350
+ return host === '127.0.0.1' || host === '::1' || host === 'localhost' || host.startsWith('127.');
351
+ }
352
+ /**
353
+ * Builds the path→workflow route table from loaded workflows. Fail-closed at startup:
354
+ * - workflows without a `trigger:` block are skipped (logged);
355
+ * - a path collision across workflows throws;
356
+ * - every referenced secret env var is resolved now (missing → throws);
357
+ * - `auth.mode === 'none'` emits a prominent warning.
358
+ */
359
+ export function buildRouteTable(inputs, deps) {
360
+ const routes = new Map();
361
+ for (const { definition, workflowDir } of inputs) {
362
+ const trigger = definition.trigger;
363
+ if (trigger === undefined) {
364
+ deps.logger.info('listen: workflow has no trigger block — not mounted', {
365
+ workflow: definition.id,
366
+ });
367
+ continue;
368
+ }
369
+ const path = trigger.path ?? `/${definition.id}`;
370
+ if (routes.has(path)) {
371
+ throw new Error(`listen: path collision on '${path}' — workflow '${definition.id}' conflicts with '${routes.get(path).definition.id}'`);
372
+ }
373
+ let secret;
374
+ const auth = trigger.auth;
375
+ if (auth.mode === 'none') {
376
+ deps.logger.warn(`listen: workflow '${definition.id}' uses auth.mode 'none' — webhook verification is DISABLED for path '${path}'. Use only on a trusted network.`);
377
+ }
378
+ else {
379
+ const value = deps.env[auth.secret_from];
380
+ if (value === undefined || value === '') {
381
+ throw new Error(`listen: workflow '${definition.id}' references secret env var '${auth.secret_from}' which is not set`);
382
+ }
383
+ secret = value;
384
+ }
385
+ routes.set(path, {
386
+ path,
387
+ definition,
388
+ trigger,
389
+ workflowDir,
390
+ ...(secret !== undefined ? { secret } : {}),
391
+ });
392
+ deps.logger.info('listen: mounted', { workflow: definition.id, path, mode: auth.mode });
393
+ }
394
+ return routes;
395
+ }
396
+ /** Wires the HTTP server + SIGTERM/SIGINT handlers. Returns a handle whose shutdown() stops
397
+ * accepting new connections; in-flight requests finish and the detached agents survive. */
398
+ export function startListen(routes, options, deps) {
399
+ const handler = makeListenHandler(routes, deps, {
400
+ maxConcurrent: options.maxConcurrent,
401
+ maxBodyBytes: options.maxBodyBytes,
402
+ bodyTimeoutMs: options.bodyTimeoutMs,
403
+ });
404
+ const server = createServer((req, res) => {
405
+ void handler(req, res).catch((err) => {
406
+ deps.logger.error('listen: unhandled handler error', { error: String(err) });
407
+ if (!res.headersSent) {
408
+ respond(res, 500, { error: 'internal_error' });
409
+ }
410
+ });
411
+ });
412
+ let shuttingDown = false;
413
+ const shutdown = (reason) => {
414
+ if (shuttingDown)
415
+ return Promise.resolve();
416
+ shuttingDown = true;
417
+ deps.logger.info('listen: shutting down', { reason });
418
+ return new Promise((resolve) => server.close(() => resolve()));
419
+ };
420
+ const onSignal = (sig) => {
421
+ void shutdown(sig).then(() => process.exit(0));
422
+ };
423
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
424
+ process.on('SIGINT', () => onSignal('SIGINT'));
425
+ return new Promise((resolve, reject) => {
426
+ let listening = false;
427
+ server.on('error', (err) => {
428
+ if (!listening)
429
+ reject(err);
430
+ else
431
+ deps.logger.error('listen: server error', { error: String(err) });
432
+ });
433
+ server.listen(options.port, options.host, () => {
434
+ listening = true;
435
+ resolve({ server, shutdown });
436
+ });
437
+ });
438
+ }
439
+ function defaultSpawnAgent(runId, cwd) {
440
+ try {
441
+ const realmBin = process.argv[1] ?? '';
442
+ const child = nodeSpawn(process.execPath, [realmBin, 'agent', '--run-id', runId], {
443
+ detached: true,
444
+ stdio: 'inherit',
445
+ cwd,
446
+ });
447
+ child.unref();
448
+ return { pid: child.pid ?? -1 };
449
+ }
450
+ catch (err) {
451
+ return { error: err instanceof Error ? err : new Error(String(err)) };
452
+ }
453
+ }
454
+ function resolveWorkflowFile(input) {
455
+ return input.endsWith('.yaml') || input.endsWith('.yml') ? input : join(input, 'workflow.yaml');
456
+ }
457
+ /**
458
+ * `realm listen` — start a webhook server that turns verified inbound webhooks into Realm runs.
459
+ *
460
+ * Serves plaintext HTTP. TLS termination, rate limiting, and autoscaling are reverse-proxy /
461
+ * serverless concerns and are intentionally NOT handled here — front this with nginx/Caddy/Traefik
462
+ * for public endpoints. The built-in floors (loopback bind, body cap/timeout, --max-concurrent) are
463
+ * the only DoS protections independent of proxy hygiene.
464
+ */
465
+ export const listenCommand = new Command('listen')
466
+ .description('Start a webhook server that creates runs from inbound webhooks (per each workflow trigger: block)')
467
+ .argument('[workflows...]', 'Workflow files or directories to mount (default: ./workflow.yaml)')
468
+ .option('--port <n>', 'Port to listen on', '3000')
469
+ .option('--host <addr>', 'Host/interface to bind (default loopback)', '127.0.0.1')
470
+ .option('--body-timeout-ms <n>', 'Max time to read a request body', '5000')
471
+ .option('--max-body-bytes <n>', 'Max request body size in bytes', '1048576')
472
+ .option('--max-concurrent <n>', 'Max in-flight requests before 503', '20')
473
+ .option('--dedup-store <kind>', 'Dedup store: file | memory', 'file')
474
+ .option('--log-level <level>', 'Log level: debug | info | warn | error', 'info')
475
+ .action(async (workflows, opts) => {
476
+ const levels = ['debug', 'info', 'warn', 'error'];
477
+ const threshold = levels.indexOf(opts['logLevel'] ?? 'info');
478
+ const log = (level) => (msg, data) => {
479
+ if (levels.indexOf(level) < threshold)
480
+ return;
481
+ const line = data !== undefined ? `${msg} ${JSON.stringify(data)}` : msg;
482
+ if (level === 'error')
483
+ console.error(line);
484
+ else if (level === 'warn')
485
+ console.warn(line);
486
+ else
487
+ console.log(line);
488
+ };
489
+ const logger = {
490
+ debug: log('debug'),
491
+ info: log('info'),
492
+ warn: log('warn'),
493
+ error: log('error'),
494
+ };
495
+ const inputs = [];
496
+ const args = workflows.length > 0 ? workflows : ['.'];
497
+ for (const input of args) {
498
+ const filePath = resolveWorkflowFile(input);
499
+ try {
500
+ const definition = loadWorkflowFromFile(filePath);
501
+ inputs.push({ definition, workflowDir: join(filePath, '..') });
502
+ }
503
+ catch (err) {
504
+ console.error(`Error: failed to load workflow '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
505
+ process.exit(1);
506
+ return;
507
+ }
508
+ }
509
+ let routes;
510
+ try {
511
+ routes = buildRouteTable(inputs, { env: process.env, logger });
512
+ }
513
+ catch (err) {
514
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
515
+ process.exit(1);
516
+ return;
517
+ }
518
+ if (routes.size === 0) {
519
+ console.error('Error: no workflows with a trigger: block to mount.');
520
+ process.exit(1);
521
+ return;
522
+ }
523
+ const host = opts['host'] ?? '127.0.0.1';
524
+ if (!isLoopbackHost(host)) {
525
+ logger.warn(`listen: binding to non-loopback host '${host}' — this server serves plaintext HTTP. Terminate TLS at a reverse proxy before exposing it.`);
526
+ }
527
+ const dedupKind = opts['dedupStore'] === 'memory' ? 'memory' : 'file';
528
+ const dedupBase = join(process.env['HOME'] ?? '.', '.realm', 'dedup');
529
+ const memoryStores = new Map();
530
+ const deps = {
531
+ workflowStore: new JsonWorkflowStore(),
532
+ runStore: new JsonFileStore(),
533
+ dedupStoreFor: (workflowId) => {
534
+ if (dedupKind === 'memory') {
535
+ let s = memoryStores.get(workflowId);
536
+ if (s === undefined) {
537
+ s = new InMemoryDedupStore();
538
+ memoryStores.set(workflowId, s);
539
+ }
540
+ return s;
541
+ }
542
+ return new FileDedupStore(dedupBase, workflowId);
543
+ },
544
+ spawnAgent: defaultSpawnAgent,
545
+ clock: () => Date.now(),
546
+ logger,
547
+ };
548
+ const handle = await startListen(routes, {
549
+ port: parseInt(opts['port'] ?? '3000', 10),
550
+ host,
551
+ bodyTimeoutMs: parseInt(opts['bodyTimeoutMs'] ?? '5000', 10),
552
+ maxBodyBytes: parseInt(opts['maxBodyBytes'] ?? '1048576', 10),
553
+ maxConcurrent: parseInt(opts['maxConcurrent'] ?? '20', 10),
554
+ }, deps);
555
+ const addr = handle.server.address();
556
+ logger.info(`realm listen on ${host}:${addr?.port ?? opts['port']} — ${routes.size} workflow(s) mounted`);
557
+ });
558
+ //# sourceMappingURL=listen.js.map