@ontrails/http 1.0.0-beta.15 → 1.0.0-beta.17

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.
package/src/build.ts CHANGED
@@ -7,48 +7,85 @@
7
7
  */
8
8
 
9
9
  import {
10
+ AuthError,
10
11
  Result,
11
- TRAILHEAD_KEY,
12
12
  ValidationError,
13
+ buildActivationProvenanceTraceAttrs,
14
+ collectAttachedTypedLayers,
13
15
  executeTrail,
14
16
  filterSurfaceTrails,
15
- validateEstablishedTopo,
17
+ getActivationWherePredicate,
18
+ getTraceSink,
19
+ LAYER_FIELD_RESERVED_NAMES,
20
+ matchesTrailPattern,
21
+ projectLayerFieldName,
22
+ TRACE_CONTEXT_KEY,
23
+ traceContextFromRecord,
24
+ validateInput,
25
+ validateWebhookSource,
26
+ validateSurfaceTopo,
27
+ verifyWebhookRequest,
28
+ writeActivationTraceRecord,
29
+ withActivationProvenance,
30
+ withSurfaceLayerNames,
31
+ zodToJsonSchema,
16
32
  } from '@ontrails/core';
17
33
  import type {
18
- Intent,
34
+ ActivationEntry,
35
+ ActivationProvenance,
36
+ ActivationSource,
37
+ AttachedTypedLayer,
38
+ BasePermit,
39
+ BaseSurfaceOptions,
19
40
  Layer,
20
41
  ResourceOverrideMap,
42
+ TraceContext,
21
43
  Topo,
22
44
  Trail,
23
45
  TrailContextInit,
46
+ WebhookSource,
47
+ WebhookVerifyRequest,
24
48
  } from '@ontrails/core';
25
49
 
50
+ import { deriveHttpInputSource, deriveHttpMethod } from './method.js';
51
+ import type { HttpMethod, InputSource } from './method.js';
52
+
53
+ export type { HttpMethod, InputSource } from './method.js';
54
+
26
55
  // ---------------------------------------------------------------------------
27
56
  // Public types
28
57
  // ---------------------------------------------------------------------------
29
58
 
30
- export interface DeriveHttpRoutesOptions {
59
+ export interface DeriveHttpRoutesOptions extends BaseSurfaceOptions {
31
60
  readonly basePath?: string | undefined;
32
- /** Config values for resources that declare a `config` schema, keyed by resource ID. */
33
- readonly configValues?:
34
- | Readonly<Record<string, Record<string, unknown>>>
35
- | undefined;
36
61
  readonly createContext?:
37
62
  | (() => TrailContextInit | Promise<TrailContextInit>)
38
63
  | undefined;
39
- readonly exclude?: readonly string[] | undefined;
40
- readonly include?: readonly string[] | undefined;
41
- readonly intent?: readonly Intent[] | undefined;
42
64
  readonly layers?: readonly Layer[] | undefined;
43
65
  readonly resources?: ResourceOverrideMap | undefined;
44
- /** Set to `false` to skip topo validation while building routes. */
45
- readonly validate?: boolean | undefined;
66
+ readonly resolvePermit?: ResolveHttpPermit | undefined;
46
67
  }
47
68
 
48
- export type HttpMethod = 'GET' | 'POST' | 'DELETE';
69
+ export type HttpHeaderSource =
70
+ | Headers
71
+ | Readonly<Record<string, string | readonly string[] | undefined>>;
49
72
 
50
- /** Input source derived from the HTTP method. */
51
- export type InputSource = 'query' | 'body';
73
+ export interface HttpExecutionContext {
74
+ readonly headers?: HttpHeaderSource | undefined;
75
+ }
76
+
77
+ export interface ResolveHttpPermitInput {
78
+ readonly authorization?: string | undefined;
79
+ readonly bearerToken?: string | undefined;
80
+ readonly headers?: HttpHeaderSource | undefined;
81
+ readonly requestId?: string | undefined;
82
+ }
83
+
84
+ export type ResolveHttpPermit = (
85
+ input: ResolveHttpPermitInput
86
+ ) =>
87
+ | Promise<Result<BasePermit | null | undefined, Error>>
88
+ | Result<BasePermit | null | undefined, Error>;
52
89
 
53
90
  export interface HttpRouteDefinition {
54
91
  readonly method: HttpMethod;
@@ -56,6 +93,38 @@ export interface HttpRouteDefinition {
56
93
  readonly trailId: string;
57
94
  readonly inputSource: InputSource;
58
95
  readonly trail: Trail<unknown, unknown, unknown>;
96
+ /**
97
+ * JSON Schema for the merged request input (trail input + projected layer
98
+ * input fields). Empty/undefined when the trail declares no input and no
99
+ * typed layer is attached. Surface adapters and OpenAPI generators read
100
+ * this to build the published request shape.
101
+ *
102
+ * @see TRL-474.
103
+ */
104
+ readonly inputSchema?: Record<string, unknown> | undefined;
105
+ /**
106
+ * Per-layer projections describing the parameter names the route accepts
107
+ * for typed layers and the routing target back onto each layer's input
108
+ * schema. Empty when the trail has no typed layer attached.
109
+ *
110
+ * Surface adapters use this to partition the parsed request into
111
+ * `{ trailInput, layerInputs }` before invoking `execute`. The `execute`
112
+ * function published below performs the same partitioning for callers
113
+ * that pass the full merged record straight through.
114
+ *
115
+ * @see TRL-474.
116
+ */
117
+ readonly layerInputProjections?: readonly HttpLayerInputProjection[];
118
+ readonly parseWebhookInput?:
119
+ | ((rawPayload: unknown) => Result<unknown, Error>)
120
+ | undefined;
121
+ readonly verifyWebhook?:
122
+ | ((request: WebhookVerifyRequest) => Promise<Result<void, Error>>)
123
+ | undefined;
124
+ readonly recordWebhookInvalid?:
125
+ | ((errorCategory?: string | undefined) => Promise<void>)
126
+ | undefined;
127
+ readonly webhookSource?: WebhookSource | undefined;
59
128
  /**
60
129
  * Validate input, compose layers, and execute the trail implementation.
61
130
  *
@@ -65,11 +134,14 @@ export interface HttpRouteDefinition {
65
134
  * @param abortSignal - Optional AbortSignal from the HTTP request. When provided,
66
135
  * it takes final precedence over any context factory signal, allowing
67
136
  * client-initiated cancellation to propagate into trail execution.
137
+ * @param context - Optional request context such as headers. When supplied,
138
+ * HTTP Bearer credentials can be resolved into `ctx.permit`.
68
139
  */
69
140
  readonly execute: (
70
141
  input: unknown,
71
142
  requestId?: string | undefined,
72
- abortSignal?: AbortSignal | undefined
143
+ abortSignal?: AbortSignal | undefined,
144
+ context?: HttpExecutionContext | undefined
73
145
  ) => Promise<Result<unknown, Error>>;
74
146
  }
75
147
 
@@ -77,16 +149,9 @@ export interface HttpRouteDefinition {
77
149
  // Internal helpers
78
150
  // ---------------------------------------------------------------------------
79
151
 
80
- /** Explicit intent → HTTP method mapping. */
81
- const intentToMethod: Record<string, HttpMethod> = {
82
- destroy: 'DELETE',
83
- read: 'GET',
84
- write: 'POST',
85
- };
86
-
87
152
  /** Derive HTTP method from trail intent. */
88
153
  const deriveMethod = (trail: Trail<unknown, unknown, unknown>): HttpMethod =>
89
- intentToMethod[trail.intent] ?? 'POST';
154
+ deriveHttpMethod(trail.intent);
90
155
 
91
156
  /** Derive HTTP path from trail ID: `entity.show` -> `/entity/show`. */
92
157
  const derivePath = (basePath: string, trailId: string): string => {
@@ -95,20 +160,466 @@ const derivePath = (basePath: string, trailId: string): string => {
95
160
  return `${base}/${segments}`;
96
161
  };
97
162
 
98
- /** Derive input source from HTTP method. */
99
- const deriveInputSource = (method: HttpMethod): InputSource =>
100
- method === 'GET' ? 'query' : 'body';
101
-
102
- /** Build per-request context overrides with the HTTP trailhead marker. */
103
- const withHttpTrailhead = (
104
- requestId: string | undefined
105
- ): Partial<TrailContextInit> => ({
106
- ...(requestId === undefined ? {} : { requestId }),
107
- extensions: {
108
- [TRAILHEAD_KEY]: 'http' as const,
163
+ /** Build per-request context overrides with the HTTP surface marker. */
164
+ const withHttpSurface = (
165
+ requestId: string | undefined,
166
+ layers: readonly Layer[]
167
+ ): Partial<TrailContextInit> =>
168
+ withSurfaceLayerNames(
169
+ 'http',
170
+ layers,
171
+ requestId === undefined ? {} : { requestId }
172
+ );
173
+
174
+ const readHeader = (
175
+ headers: HttpHeaderSource | undefined,
176
+ name: string
177
+ ): string | undefined => {
178
+ if (headers === undefined) {
179
+ return undefined;
180
+ }
181
+ if (headers instanceof Headers) {
182
+ return headers.get(name) ?? undefined;
183
+ }
184
+ const needle = name.toLowerCase();
185
+ for (const [key, value] of Object.entries(headers)) {
186
+ if (key.toLowerCase() !== needle || value === undefined) {
187
+ continue;
188
+ }
189
+ return typeof value === 'string' ? value : value[0];
190
+ }
191
+ return undefined;
192
+ };
193
+
194
+ const parseBearerAuthorization = (
195
+ authorization: string | undefined
196
+ ): Result<string | undefined, Error> => {
197
+ if (authorization === undefined || authorization.length === 0) {
198
+ return Result.ok();
199
+ }
200
+ const match = authorization.match(/^Bearer\s+(.+)$/i);
201
+ const token = match?.[1]?.trim();
202
+ if (token === undefined || token.length === 0) {
203
+ return Result.err(
204
+ new AuthError('Malformed Authorization header; expected Bearer token', {
205
+ context: { code: 'invalid_authorization_header' },
206
+ })
207
+ );
208
+ }
209
+ return Result.ok(token);
210
+ };
211
+
212
+ const isBearerAuthorization = (authorization: string | undefined): boolean =>
213
+ authorization !== undefined && /^Bearer(?:\s|$)/i.test(authorization.trim());
214
+
215
+ const shouldResolveHttpPermit = (
216
+ options: DeriveHttpRoutesOptions,
217
+ authorization: string | undefined,
218
+ requiresPermit: boolean
219
+ ): boolean =>
220
+ requiresPermit ||
221
+ (options.resolvePermit !== undefined && isBearerAuthorization(authorization));
222
+
223
+ const resolveHttpPermit = async (
224
+ options: DeriveHttpRoutesOptions,
225
+ request: HttpExecutionContext | undefined,
226
+ requestId: string | undefined,
227
+ requiresPermit: boolean
228
+ ): Promise<Result<BasePermit | undefined, Error>> => {
229
+ const authorization = readHeader(request?.headers, 'authorization');
230
+ if (!shouldResolveHttpPermit(options, authorization, requiresPermit)) {
231
+ return Result.ok();
232
+ }
233
+ const token = parseBearerAuthorization(authorization);
234
+ if (token.isErr()) {
235
+ return token;
236
+ }
237
+ if (token.value === undefined) {
238
+ return Result.ok();
239
+ }
240
+ if (options.resolvePermit === undefined) {
241
+ return Result.ok();
242
+ }
243
+ const resolved = await options.resolvePermit({
244
+ authorization,
245
+ bearerToken: token.value,
246
+ headers: request?.headers,
247
+ requestId,
248
+ });
249
+ if (resolved.isErr()) {
250
+ return resolved;
251
+ }
252
+ return Result.ok(resolved.value ?? undefined);
253
+ };
254
+
255
+ const createWebhookActivationFireId = (): string => {
256
+ const randomUUID = globalThis.crypto?.randomUUID;
257
+ if (typeof randomUUID === 'function') {
258
+ return randomUUID.call(globalThis.crypto);
259
+ }
260
+ return `webhook_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
261
+ };
262
+
263
+ const webhookActivationProvenance = (
264
+ source: WebhookSource,
265
+ fireId: string
266
+ ): ActivationProvenance => ({
267
+ fireId,
268
+ rootFireId: fireId,
269
+ source: {
270
+ id: source.id,
271
+ kind: 'webhook',
272
+ ...(source.meta === undefined ? {} : { meta: source.meta }),
109
273
  },
110
274
  });
111
275
 
276
+ const webhookActivationTraceAttrs = (
277
+ source: WebhookSource,
278
+ activation: ActivationProvenance,
279
+ trailId: string
280
+ ): Readonly<Record<string, unknown>> => ({
281
+ ...buildActivationProvenanceTraceAttrs(activation),
282
+ 'trails.activation.target_trail.id': trailId,
283
+ 'trails.activation.webhook.method': source.method,
284
+ 'trails.activation.webhook.path': source.path,
285
+ });
286
+
287
+ const recordWebhookActivationTrace = async (
288
+ graph: Topo,
289
+ source: WebhookSource,
290
+ activation: ActivationProvenance,
291
+ trailId: string,
292
+ name: 'activation.webhook' | 'activation.webhook.invalid',
293
+ status: 'err' | 'ok',
294
+ errorCategory?: string | undefined
295
+ ): Promise<TraceContext | undefined> => {
296
+ const record = await writeActivationTraceRecord(
297
+ name,
298
+ webhookActivationTraceAttrs(source, activation, trailId),
299
+ status,
300
+ errorCategory,
301
+ undefined,
302
+ graph.observe?.trace ?? getTraceSink()
303
+ );
304
+ return record === undefined ? undefined : traceContextFromRecord(record);
305
+ };
306
+
307
+ /**
308
+ * Internal recorder signature for webhook invalid traces.
309
+ *
310
+ * Unlike the public `HttpRouteDefinition['recordWebhookInvalid']`, this
311
+ * accepts an `activationFireId` so a single inbound failed request can share
312
+ * one activation fire ID across every consumer fan-out — letting
313
+ * observability correlate sibling consumers' invalid records as one
314
+ * activation root, mirroring the success path.
315
+ */
316
+ type WebhookInvalidConsumerRecorder = (
317
+ errorCategory: string | undefined,
318
+ activationFireId: string
319
+ ) => Promise<void>;
320
+
321
+ const createWebhookInvalidRecorder =
322
+ (
323
+ graph: Topo,
324
+ source: WebhookSource,
325
+ trailId: string
326
+ ): WebhookInvalidConsumerRecorder =>
327
+ async (errorCategory, activationFireId) => {
328
+ const activation = webhookActivationProvenance(source, activationFireId);
329
+ await recordWebhookActivationTrace(
330
+ graph,
331
+ source,
332
+ activation,
333
+ trailId,
334
+ 'activation.webhook.invalid',
335
+ 'err',
336
+ errorCategory ?? 'validation'
337
+ );
338
+ };
339
+
340
+ /**
341
+ * Wrap a single-consumer invalid recorder as the public `recordWebhookInvalid`
342
+ * function. Generates one activation fire ID per inbound failed request,
343
+ * matching the fan-out behavior so single and merged routes share the same
344
+ * observability shape.
345
+ */
346
+ const createWebhookInvalidPublicRecorder =
347
+ (
348
+ consumerRecorder: WebhookInvalidConsumerRecorder
349
+ ): NonNullable<HttpRouteDefinition['recordWebhookInvalid']> =>
350
+ async (errorCategory = 'validation') =>
351
+ await consumerRecorder(errorCategory, createWebhookActivationFireId());
352
+
353
+ const withWebhookActivation = (
354
+ activation: ActivationProvenance,
355
+ requestId: string | undefined,
356
+ traceContext: TraceContext | undefined,
357
+ layers: readonly Layer[]
358
+ ): Partial<TrailContextInit> => {
359
+ const ctx = withActivationProvenance(
360
+ withHttpSurface(requestId, layers),
361
+ activation
362
+ );
363
+ return traceContext === undefined
364
+ ? ctx
365
+ : {
366
+ ...ctx,
367
+ extensions: {
368
+ ...ctx.extensions,
369
+ [TRACE_CONTEXT_KEY]: traceContext,
370
+ },
371
+ };
372
+ };
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Layer input projection (TRL-474)
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /**
379
+ * Per-layer projection onto an HTTP route's request input.
380
+ *
381
+ * `routing` maps the parameter name a consumer sees on the request (a query
382
+ * key for `intent: 'read'`, a body field for write/destroy) to the authored
383
+ * field name on the layer's input schema. When no rename was required the
384
+ * two are the same; on collision the parameter name carries the layer
385
+ * prefix while the routing target preserves the original field.
386
+ */
387
+ export interface HttpLayerInputProjection {
388
+ readonly layerName: string;
389
+ /** parameterName → originalFieldName for this layer. */
390
+ readonly routing: ReadonlyMap<string, string>;
391
+ /** Fragment merged into the route's `inputSchema.properties`. */
392
+ readonly properties: Readonly<Record<string, unknown>>;
393
+ /** Field names appended to the route's `inputSchema.required` list. */
394
+ readonly required: readonly string[];
395
+ }
396
+
397
+ const buildHttpRenameTarget = (
398
+ layerName: string,
399
+ originalName: string
400
+ ): string => {
401
+ if (originalName.length === 0) {
402
+ return layerName;
403
+ }
404
+ const [head, ...rest] = originalName;
405
+ if (head === undefined) {
406
+ return layerName;
407
+ }
408
+ return `${layerName}${head.toUpperCase()}${rest.join('')}`;
409
+ };
410
+
411
+ const isJsonObjectSchema = (
412
+ value: unknown
413
+ ): value is { properties?: Record<string, unknown>; required?: string[] } =>
414
+ typeof value === 'object' && value !== null && !Array.isArray(value);
415
+
416
+ const readRequiredFields = (value: unknown): readonly string[] => {
417
+ if (!isJsonObjectSchema(value) || !Array.isArray(value.required)) {
418
+ return [];
419
+ }
420
+ return value.required.every((field) => typeof field === 'string')
421
+ ? value.required
422
+ : [];
423
+ };
424
+
425
+ const projectHttpLayerInput = (
426
+ layer: Layer,
427
+ claimedNames: Set<string>
428
+ ): HttpLayerInputProjection => {
429
+ if (layer.input === undefined) {
430
+ return {
431
+ layerName: layer.name,
432
+ properties: {},
433
+ required: [],
434
+ routing: new Map(),
435
+ };
436
+ }
437
+
438
+ const layerSchema = zodToJsonSchema(layer.input);
439
+ const properties: Record<string, unknown> = {};
440
+ const required: string[] = [];
441
+ const routing = new Map<string, string>();
442
+
443
+ if (
444
+ !isJsonObjectSchema(layerSchema) ||
445
+ layerSchema.properties === undefined
446
+ ) {
447
+ return {
448
+ layerName: layer.name,
449
+ properties,
450
+ required,
451
+ routing,
452
+ };
453
+ }
454
+
455
+ const requiredSet = new Set<string>(layerSchema.required);
456
+ for (const [fieldName, fieldSchema] of Object.entries(
457
+ layerSchema.properties
458
+ )) {
459
+ const renamed = buildHttpRenameTarget(layer.name, fieldName);
460
+ const projection = projectLayerFieldName(
461
+ layer.name,
462
+ fieldName,
463
+ fieldName,
464
+ renamed,
465
+ claimedNames,
466
+ LAYER_FIELD_RESERVED_NAMES
467
+ );
468
+ properties[projection.claimedName] = fieldSchema;
469
+ if (requiredSet.has(fieldName)) {
470
+ required.push(projection.claimedName);
471
+ }
472
+ routing.set(projection.claimedName, projection.routingTarget);
473
+ }
474
+
475
+ return { layerName: layer.name, properties, required, routing };
476
+ };
477
+
478
+ interface HttpInputProjection {
479
+ readonly schema: Record<string, unknown> | undefined;
480
+ readonly projections: readonly HttpLayerInputProjection[];
481
+ }
482
+
483
+ const projectHttpInputSchema = (
484
+ trail: Trail<unknown, unknown, unknown>,
485
+ attachedLayers: readonly AttachedTypedLayer[]
486
+ ): HttpInputProjection => {
487
+ const baseSchema = zodToJsonSchema(trail.input);
488
+ if (attachedLayers.length === 0) {
489
+ return { projections: [], schema: baseSchema };
490
+ }
491
+
492
+ const baseProperties =
493
+ isJsonObjectSchema(baseSchema) && baseSchema.properties !== undefined
494
+ ? baseSchema.properties
495
+ : undefined;
496
+ const baseRequired =
497
+ isJsonObjectSchema(baseSchema) && Array.isArray(baseSchema.required)
498
+ ? baseSchema.required
499
+ : [];
500
+
501
+ const claimedNames = new Set<string>(
502
+ baseProperties === undefined ? [] : Object.keys(baseProperties)
503
+ );
504
+
505
+ const mergedProperties: Record<string, unknown> = {
506
+ ...baseProperties,
507
+ };
508
+ const mergedRequired = [...baseRequired];
509
+ const projections: HttpLayerInputProjection[] = [];
510
+
511
+ for (const { layer } of attachedLayers) {
512
+ const projection = projectHttpLayerInput(layer, claimedNames);
513
+ if (projection.routing.size === 0) {
514
+ continue;
515
+ }
516
+ Object.assign(mergedProperties, projection.properties);
517
+ mergedRequired.push(...projection.required);
518
+ projections.push(projection);
519
+ }
520
+
521
+ if (projections.length === 0) {
522
+ return { projections: [], schema: baseSchema };
523
+ }
524
+
525
+ const mergedSchema: Record<string, unknown> = isJsonObjectSchema(baseSchema)
526
+ ? { ...baseSchema, properties: mergedProperties, type: 'object' }
527
+ : { properties: mergedProperties, type: 'object' };
528
+ if (mergedRequired.length > 0) {
529
+ mergedSchema['required'] = mergedRequired;
530
+ } else if ('required' in mergedSchema) {
531
+ delete mergedSchema['required'];
532
+ }
533
+
534
+ return { projections, schema: mergedSchema };
535
+ };
536
+
537
+ const mergeHttpInputSchemas = (
538
+ left: Record<string, unknown> | undefined,
539
+ right: Record<string, unknown> | undefined
540
+ ): Record<string, unknown> | undefined => {
541
+ if (left === undefined) {
542
+ return right;
543
+ }
544
+ if (right === undefined) {
545
+ return left;
546
+ }
547
+ const leftProperties =
548
+ isJsonObjectSchema(left) && left.properties !== undefined
549
+ ? left.properties
550
+ : undefined;
551
+ const rightProperties =
552
+ isJsonObjectSchema(right) && right.properties !== undefined
553
+ ? right.properties
554
+ : undefined;
555
+ const merged: Record<string, unknown> = {
556
+ ...left,
557
+ ...right,
558
+ properties: {
559
+ ...leftProperties,
560
+ ...rightProperties,
561
+ },
562
+ type: 'object',
563
+ };
564
+ const required = [
565
+ ...new Set([...readRequiredFields(left), ...readRequiredFields(right)]),
566
+ ];
567
+ if (required.length > 0) {
568
+ merged['required'] = required;
569
+ } else {
570
+ delete merged['required'];
571
+ }
572
+ return merged;
573
+ };
574
+
575
+ /**
576
+ * Partition a parsed request input into the trail input plus per-layer
577
+ * inputs, using each layer's routing table.
578
+ *
579
+ * Layer-projected parameter names are stripped from the trail input so the
580
+ * trail's schema validation only ever sees its own fields. A layer that
581
+ * received no parameters is omitted from `layerInputs` so consumers can
582
+ * cleanly assert which layers were activated by the request.
583
+ */
584
+ const partitionHttpInput = (
585
+ input: unknown,
586
+ projections: readonly HttpLayerInputProjection[]
587
+ ): {
588
+ readonly trailInput: unknown;
589
+ readonly layerInputs: Record<string, unknown>;
590
+ } => {
591
+ if (projections.length === 0 || !isJsonObjectSchema(input)) {
592
+ return { layerInputs: {}, trailInput: input };
593
+ }
594
+ const record = input as Record<string, unknown>;
595
+ const claimedKeys = new Set<string>();
596
+ const layerInputs: Record<string, unknown> = {};
597
+ for (const projection of projections) {
598
+ const layerInput: Record<string, unknown> = {};
599
+ let received = false;
600
+ for (const [paramName, fieldName] of projection.routing) {
601
+ claimedKeys.add(paramName);
602
+ const value = record[paramName];
603
+ if (value === undefined) {
604
+ continue;
605
+ }
606
+ layerInput[fieldName] = value;
607
+ received = true;
608
+ }
609
+ if (received) {
610
+ layerInputs[projection.layerName] = layerInput;
611
+ }
612
+ }
613
+ const trailInput: Record<string, unknown> = {};
614
+ for (const [key, value] of Object.entries(record)) {
615
+ if (claimedKeys.has(key)) {
616
+ continue;
617
+ }
618
+ trailInput[key] = value;
619
+ }
620
+ return { layerInputs, trailInput };
621
+ };
622
+
112
623
  // ---------------------------------------------------------------------------
113
624
  // Execute factory
114
625
  // ---------------------------------------------------------------------------
@@ -124,18 +635,136 @@ const createExecute =
124
635
  graph: Topo,
125
636
  t: Trail<unknown, unknown, unknown>,
126
637
  layers: readonly Layer[],
127
- options: DeriveHttpRoutesOptions
638
+ options: DeriveHttpRoutesOptions,
639
+ layerProjections: readonly HttpLayerInputProjection[]
128
640
  ): HttpRouteDefinition['execute'] =>
129
- (input, requestId, abortSignal) =>
130
- executeTrail(t, input, {
641
+ async (input, requestId, abortSignal, request) => {
642
+ const { trailInput, layerInputs } = partitionHttpInput(
643
+ input,
644
+ layerProjections
645
+ );
646
+ const permitResolution = await resolveHttpPermit(
647
+ options,
648
+ request,
649
+ requestId,
650
+ t.permit !== undefined
651
+ );
652
+ if (permitResolution.isErr()) {
653
+ return Result.err(permitResolution.error);
654
+ }
655
+ const permit = permitResolution.value;
656
+ return await executeTrail(t, trailInput, {
131
657
  abortSignal,
132
658
  configValues: options.configValues,
133
659
  createContext: options.createContext,
134
- ctx: withHttpTrailhead(requestId),
135
- layers,
660
+ ctx: withHttpSurface(requestId, layers),
661
+ ...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
662
+ ...(permit === undefined ? {} : { permit }),
136
663
  resources: options.resources,
664
+ surfaceLayers: layers,
137
665
  topo: graph,
666
+ topoLayers: graph.layers,
138
667
  });
668
+ };
669
+
670
+ /**
671
+ * Internal executor signature used for shared webhook source fan-out.
672
+ *
673
+ * Unlike the public `HttpRouteDefinition['execute']`, this accepts an
674
+ * `activationFireId` so a single inbound request can share one activation
675
+ * fire ID across every consumer fan-out — letting observability correlate
676
+ * sibling consumers as one activation root.
677
+ */
678
+ type WebhookConsumerExecute = (
679
+ input: unknown,
680
+ requestId: string | undefined,
681
+ abortSignal: AbortSignal | undefined,
682
+ request: HttpExecutionContext | undefined,
683
+ activationFireId: string
684
+ ) => Promise<Result<unknown, Error>>;
685
+
686
+ const createWebhookConsumerExecute =
687
+ (
688
+ graph: Topo,
689
+ t: Trail<unknown, unknown, unknown>,
690
+ activationEntry: ActivationEntry,
691
+ source: WebhookSource,
692
+ layers: readonly Layer[],
693
+ options: DeriveHttpRoutesOptions,
694
+ layerProjections: readonly HttpLayerInputProjection[]
695
+ ): WebhookConsumerExecute =>
696
+ async (input, requestId, abortSignal, request, activationFireId) => {
697
+ const predicate = getActivationWherePredicate(activationEntry.where);
698
+ if (predicate !== undefined) {
699
+ let shouldRun = false;
700
+ try {
701
+ shouldRun = await predicate(input);
702
+ } catch (error) {
703
+ return Result.err(
704
+ new ValidationError(
705
+ `Webhook source "${source.id}" activation predicate failed`,
706
+ { cause: error instanceof Error ? error : new Error(String(error)) }
707
+ )
708
+ );
709
+ }
710
+ if (!shouldRun) {
711
+ return Result.ok();
712
+ }
713
+ }
714
+
715
+ const activation = webhookActivationProvenance(source, activationFireId);
716
+ const traceContext = await recordWebhookActivationTrace(
717
+ graph,
718
+ source,
719
+ activation,
720
+ t.id,
721
+ 'activation.webhook',
722
+ 'ok'
723
+ );
724
+ const { trailInput, layerInputs } = partitionHttpInput(
725
+ input,
726
+ layerProjections
727
+ );
728
+ const permitResolution = await resolveHttpPermit(
729
+ options,
730
+ request,
731
+ requestId,
732
+ t.permit !== undefined
733
+ );
734
+ if (permitResolution.isErr()) {
735
+ return Result.err(permitResolution.error);
736
+ }
737
+ const permit = permitResolution.value;
738
+ return await executeTrail(t, trailInput, {
739
+ abortSignal,
740
+ configValues: options.configValues,
741
+ createContext: options.createContext,
742
+ ctx: withWebhookActivation(activation, requestId, traceContext, layers),
743
+ ...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
744
+ ...(permit === undefined ? {} : { permit }),
745
+ resources: options.resources,
746
+ surfaceLayers: layers,
747
+ topo: graph,
748
+ topoLayers: graph.layers,
749
+ });
750
+ };
751
+
752
+ /**
753
+ * Wrap a single-consumer webhook executor as the public `execute` function.
754
+ *
755
+ * Generates one activation fire ID per inbound request, matching the fan-out
756
+ * behavior so single and merged routes share the same observability shape.
757
+ */
758
+ const createWebhookExecute =
759
+ (consumerExecute: WebhookConsumerExecute): HttpRouteDefinition['execute'] =>
760
+ async (input, requestId, abortSignal, request) =>
761
+ await consumerExecute(
762
+ input,
763
+ requestId,
764
+ abortSignal,
765
+ request,
766
+ createWebhookActivationFireId()
767
+ );
139
768
 
140
769
  // ---------------------------------------------------------------------------
141
770
  // Builder helpers
@@ -152,6 +781,45 @@ const eligibleTrails = (
152
781
  intent: options.intent,
153
782
  });
154
783
 
784
+ const isInternalTrail = (trail: Trail<unknown, unknown, unknown>): boolean =>
785
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
786
+
787
+ const matchesAnyPattern = (
788
+ trailId: string,
789
+ patterns: readonly string[] | undefined
790
+ ): boolean =>
791
+ patterns !== undefined &&
792
+ patterns.some((pattern) => matchesTrailPattern(trailId, pattern));
793
+
794
+ const passesIncludeFilter = (
795
+ trailId: string,
796
+ include: readonly string[] | undefined
797
+ ): boolean =>
798
+ include === undefined ||
799
+ include.length === 0 ||
800
+ matchesAnyPattern(trailId, include);
801
+
802
+ const eligibleWebhookTrails = (
803
+ graph: Topo,
804
+ options: DeriveHttpRoutesOptions
805
+ ): Trail<unknown, unknown, unknown>[] =>
806
+ graph.list().filter((trail) => {
807
+ if (isInternalTrail(trail) && !options.include?.includes(trail.id)) {
808
+ return false;
809
+ }
810
+ if (matchesAnyPattern(trail.id, options.exclude)) {
811
+ return false;
812
+ }
813
+ if (!passesIncludeFilter(trail.id, options.include)) {
814
+ return false;
815
+ }
816
+ return (
817
+ options.intent === undefined ||
818
+ options.intent.length === 0 ||
819
+ options.intent.includes(trail.intent)
820
+ );
821
+ });
822
+
155
823
  /** Build a single route definition from a trail. */
156
824
  const buildRoute = (
157
825
  graph: Topo,
@@ -162,9 +830,27 @@ const buildRoute = (
162
830
  ): HttpRouteDefinition => {
163
831
  const method = deriveMethod(trail);
164
832
  const path = derivePath(basePath, trail.id);
833
+ const attachedLayers = collectAttachedTypedLayers(
834
+ graph,
835
+ trail,
836
+ options.layers
837
+ );
838
+ const inputProjection = projectHttpInputSchema(trail, attachedLayers);
165
839
  return {
166
- execute: createExecute(graph, trail, layers, options),
167
- inputSource: deriveInputSource(method),
840
+ execute: createExecute(
841
+ graph,
842
+ trail,
843
+ layers,
844
+ options,
845
+ inputProjection.projections
846
+ ),
847
+ ...(inputProjection.schema === undefined
848
+ ? {}
849
+ : { inputSchema: inputProjection.schema }),
850
+ inputSource: deriveHttpInputSource(method),
851
+ ...(inputProjection.projections.length === 0
852
+ ? {}
853
+ : { layerInputProjections: inputProjection.projections }),
168
854
  method,
169
855
  path,
170
856
  trail,
@@ -172,6 +858,160 @@ const buildRoute = (
172
858
  };
173
859
  };
174
860
 
861
+ const normalizeSourcePath = (basePath: string, sourcePath: string): string => {
862
+ const base = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
863
+ return `${base}${sourcePath}`;
864
+ };
865
+
866
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
867
+ typeof value === 'object' && value !== null && !Array.isArray(value);
868
+
869
+ type ZodSchemaInput = Parameters<typeof validateInput>[0];
870
+
871
+ const isZodSchema = (value: unknown): value is ZodSchemaInput =>
872
+ isObjectRecord(value) && typeof value['safeParse'] === 'function';
873
+
874
+ const parseOutputSchema = (
875
+ parse: WebhookSource['parse'] | undefined
876
+ ): ZodSchemaInput | undefined => {
877
+ if (isZodSchema(parse)) {
878
+ return parse;
879
+ }
880
+ if (isObjectRecord(parse) && isZodSchema(parse['output'])) {
881
+ return parse['output'];
882
+ }
883
+ return undefined;
884
+ };
885
+
886
+ const webhookValidationMessage = (
887
+ source: ActivationSource,
888
+ issues: ReturnType<typeof validateWebhookSource>
889
+ ): string =>
890
+ `Webhook source "${source.id}" is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join('; ')}`;
891
+
892
+ const toWebhookSource = (
893
+ source: ActivationSource
894
+ ): Result<WebhookSource, Error> => {
895
+ const issues = validateWebhookSource(source);
896
+ if (issues.length > 0) {
897
+ return Result.err(
898
+ new ValidationError(webhookValidationMessage(source, issues), {
899
+ context: { issues },
900
+ })
901
+ );
902
+ }
903
+ const webhookSource = source as WebhookSource;
904
+ const method = (source.method ?? 'POST').trim().toUpperCase();
905
+ const path = source.path?.trim();
906
+ if (method === webhookSource.method && path === webhookSource.path) {
907
+ return Result.ok(webhookSource);
908
+ }
909
+ return Result.ok(
910
+ Object.freeze({
911
+ ...webhookSource,
912
+ method,
913
+ path,
914
+ }) as WebhookSource
915
+ );
916
+ };
917
+
918
+ const createWebhookInputParser =
919
+ (source: WebhookSource): HttpRouteDefinition['parseWebhookInput'] =>
920
+ (rawPayload) => {
921
+ const schema = parseOutputSchema(source.parse);
922
+ if (schema === undefined) {
923
+ return Result.err(
924
+ new ValidationError(
925
+ `Webhook source "${source.id}" does not expose a parse output schema`
926
+ )
927
+ );
928
+ }
929
+ const parsed = validateInput(schema, rawPayload);
930
+ if (parsed.isErr()) {
931
+ return Result.err(
932
+ new ValidationError(
933
+ `Webhook source "${source.id}" payload is invalid: ${parsed.error.message}`,
934
+ {
935
+ cause: parsed.error,
936
+ ...(parsed.error.context === undefined
937
+ ? {}
938
+ : { context: parsed.error.context }),
939
+ }
940
+ )
941
+ );
942
+ }
943
+ return parsed;
944
+ };
945
+
946
+ const WEBHOOK_CONSUMERS = Symbol('webhookConsumers');
947
+ const WEBHOOK_INVALID_RECORDERS = Symbol('webhookInvalidRecorders');
948
+
949
+ type WebhookInvalidRecorder = NonNullable<
950
+ HttpRouteDefinition['recordWebhookInvalid']
951
+ >;
952
+
953
+ type MergeableWebhookRoute = HttpRouteDefinition & {
954
+ readonly [WEBHOOK_CONSUMERS]?: readonly WebhookConsumerExecute[];
955
+ readonly [WEBHOOK_INVALID_RECORDERS]?: readonly WebhookInvalidConsumerRecorder[];
956
+ };
957
+
958
+ const buildWebhookRoute = (
959
+ graph: Topo,
960
+ trail: Trail<unknown, unknown, unknown>,
961
+ activation: ActivationEntry,
962
+ basePath: string,
963
+ layers: readonly Layer[],
964
+ options: DeriveHttpRoutesOptions
965
+ ): Result<HttpRouteDefinition, Error> => {
966
+ const source = toWebhookSource(activation.source);
967
+ if (source.isErr()) {
968
+ return source;
969
+ }
970
+ const attachedLayers = collectAttachedTypedLayers(
971
+ graph,
972
+ trail,
973
+ options.layers
974
+ );
975
+ const inputProjection = projectHttpInputSchema(trail, attachedLayers);
976
+ const consumerExecute = createWebhookConsumerExecute(
977
+ graph,
978
+ trail,
979
+ activation,
980
+ source.value,
981
+ layers,
982
+ options,
983
+ inputProjection.projections
984
+ );
985
+ const consumerInvalidRecorder = createWebhookInvalidRecorder(
986
+ graph,
987
+ source.value,
988
+ trail.id
989
+ );
990
+ const route: MergeableWebhookRoute = {
991
+ [WEBHOOK_CONSUMERS]: [consumerExecute],
992
+ [WEBHOOK_INVALID_RECORDERS]: [consumerInvalidRecorder],
993
+ execute: createWebhookExecute(consumerExecute),
994
+ ...(inputProjection.schema === undefined
995
+ ? {}
996
+ : { inputSchema: inputProjection.schema }),
997
+ inputSource: 'webhook',
998
+ ...(inputProjection.projections.length === 0
999
+ ? {}
1000
+ : { layerInputProjections: inputProjection.projections }),
1001
+ method: source.value.method,
1002
+ parseWebhookInput: createWebhookInputParser(source.value),
1003
+ path: normalizeSourcePath(basePath, source.value.path),
1004
+ recordWebhookInvalid: createWebhookInvalidPublicRecorder(
1005
+ consumerInvalidRecorder
1006
+ ),
1007
+ trail,
1008
+ trailId: trail.id,
1009
+ verifyWebhook: (request) => verifyWebhookRequest(source.value, request),
1010
+ webhookSource: source.value,
1011
+ };
1012
+ return Result.ok(route);
1013
+ };
1014
+
175
1015
  // ---------------------------------------------------------------------------
176
1016
  // Collision detection
177
1017
  // ---------------------------------------------------------------------------
@@ -180,22 +1020,200 @@ const buildRoute = (
180
1020
  const routeKey = (route: HttpRouteDefinition): `${string} ${string}` =>
181
1021
  `${route.method} ${route.path}`;
182
1022
 
1023
+ const isSameWebhookSourceLocation = (
1024
+ left: HttpRouteDefinition,
1025
+ right: HttpRouteDefinition
1026
+ ): boolean =>
1027
+ left.inputSource === 'webhook' &&
1028
+ right.inputSource === 'webhook' &&
1029
+ left.webhookSource !== undefined &&
1030
+ right.webhookSource !== undefined &&
1031
+ left.webhookSource.id === right.webhookSource.id &&
1032
+ left.webhookSource.method === right.webhookSource.method &&
1033
+ left.webhookSource.path === right.webhookSource.path;
1034
+
1035
+ /**
1036
+ * Two webhook source routes can only merge when they declare the same
1037
+ * verifier identity. Reference equality on `verify` matches the projection
1038
+ * model used elsewhere — a shared source object always passes, while two
1039
+ * separately-declared verifier functions are treated as distinct policies
1040
+ * even when their bodies look equivalent.
1041
+ */
1042
+ const hasMatchingWebhookVerifier = (
1043
+ left: HttpRouteDefinition,
1044
+ right: HttpRouteDefinition
1045
+ ): boolean => left.webhookSource?.verify === right.webhookSource?.verify;
1046
+
1047
+ /**
1048
+ * Two webhook source routes can only merge when they declare the same parse
1049
+ * contract identity. Reference equality on `parse` mirrors the verifier rule:
1050
+ * a shared source object always passes, while two separately-declared parse
1051
+ * schemas (or handlers) are treated as distinct contracts even when their
1052
+ * shapes look equivalent. Without this check the merged route silently keeps
1053
+ * whichever parser registered first, so payloads valid for later consumers
1054
+ * could be rejected and unintended shapes could be passed downstream.
1055
+ */
1056
+ const hasMatchingWebhookParse = (
1057
+ left: HttpRouteDefinition,
1058
+ right: HttpRouteDefinition
1059
+ ): boolean => left.webhookSource?.parse === right.webhookSource?.parse;
1060
+
1061
+ const webhookConsumers = (
1062
+ route: MergeableWebhookRoute
1063
+ ): readonly WebhookConsumerExecute[] | undefined => route[WEBHOOK_CONSUMERS];
1064
+
1065
+ const webhookInvalidRecorders = (
1066
+ route: MergeableWebhookRoute
1067
+ ): readonly WebhookInvalidConsumerRecorder[] =>
1068
+ route[WEBHOOK_INVALID_RECORDERS] ?? [];
1069
+
1070
+ type MergeWebhookOutcome =
1071
+ | { readonly kind: 'merged'; readonly route: HttpRouteDefinition }
1072
+ | { readonly kind: 'verifier-mismatch'; readonly error: ValidationError }
1073
+ | { readonly kind: 'parse-mismatch'; readonly error: ValidationError }
1074
+ | { readonly kind: 'not-mergeable' };
1075
+
1076
+ const mergeWebhookRoutes = (
1077
+ existing: HttpRouteDefinition,
1078
+ route: HttpRouteDefinition
1079
+ ): MergeWebhookOutcome => {
1080
+ if (!isSameWebhookSourceLocation(existing, route)) {
1081
+ return { kind: 'not-mergeable' };
1082
+ }
1083
+ if (!hasMatchingWebhookVerifier(existing, route)) {
1084
+ return {
1085
+ error: new ValidationError(
1086
+ `HTTP route collision: trails "${existing.trailId}" and "${route.trailId}" share webhook source "${existing.webhookSource?.id}" on ${route.method} ${route.path} but declare a mismatched webhook verifier policy. Reuse the same WebhookSource object so both consumers run under one verifier.`
1087
+ ),
1088
+ kind: 'verifier-mismatch',
1089
+ };
1090
+ }
1091
+ if (!hasMatchingWebhookParse(existing, route)) {
1092
+ return {
1093
+ error: new ValidationError(
1094
+ `HTTP route collision: trails "${existing.trailId}" and "${route.trailId}" share webhook source "${existing.webhookSource?.id}" on ${route.method} ${route.path} but declare a mismatched webhook parse contract. Reuse the same WebhookSource object so both consumers parse payloads under one contract.`
1095
+ ),
1096
+ kind: 'parse-mismatch',
1097
+ };
1098
+ }
1099
+
1100
+ const existingConsumers = webhookConsumers(existing as MergeableWebhookRoute);
1101
+ const incomingConsumers = webhookConsumers(route as MergeableWebhookRoute);
1102
+ if (existingConsumers === undefined || incomingConsumers === undefined) {
1103
+ return { kind: 'not-mergeable' };
1104
+ }
1105
+ const consumers: readonly WebhookConsumerExecute[] = [
1106
+ ...existingConsumers,
1107
+ ...incomingConsumers,
1108
+ ];
1109
+
1110
+ const recorders = [
1111
+ ...webhookInvalidRecorders(existing as MergeableWebhookRoute),
1112
+ ...webhookInvalidRecorders(route as MergeableWebhookRoute),
1113
+ ] as const;
1114
+
1115
+ // Fan-out: every consumer's recorder must fire on parse/verify failures so
1116
+ // each trail emits its own activation.webhook.invalid trace record. A
1117
+ // single activation fire ID is generated per inbound failed request and
1118
+ // shared across all recorders so observability can correlate the sibling
1119
+ // invalid records as one activation root, mirroring the success path. A
1120
+ // recorder failure must not prevent the remaining recorders from running.
1121
+ const recordWebhookInvalidFanOut: WebhookInvalidRecorder | undefined =
1122
+ recorders.length === 0
1123
+ ? undefined
1124
+ : async (errorCategory) => {
1125
+ const activationFireId = createWebhookActivationFireId();
1126
+ await Promise.all(
1127
+ recorders.map(async (record) => {
1128
+ try {
1129
+ await record(errorCategory, activationFireId);
1130
+ } catch {
1131
+ // Recorder failures must never short-circuit the fan-out;
1132
+ // sink errors are already swallowed inside writeToSink.
1133
+ }
1134
+ })
1135
+ );
1136
+ };
1137
+
1138
+ const merged: MergeableWebhookRoute = {
1139
+ ...existing,
1140
+ [WEBHOOK_CONSUMERS]: consumers,
1141
+ [WEBHOOK_INVALID_RECORDERS]: recorders,
1142
+ inputSchema: mergeHttpInputSchemas(existing.inputSchema, route.inputSchema),
1143
+ layerInputProjections: [
1144
+ ...(existing.layerInputProjections ?? []),
1145
+ ...(route.layerInputProjections ?? []),
1146
+ ],
1147
+ ...(recordWebhookInvalidFanOut === undefined
1148
+ ? {}
1149
+ : { recordWebhookInvalid: recordWebhookInvalidFanOut }),
1150
+ async execute(input, requestId, abortSignal, request) {
1151
+ // One activation fire ID per inbound webhook request, shared across every
1152
+ // fan-out consumer so observability can correlate them as siblings of a
1153
+ // single activation root.
1154
+ const activationFireId = createWebhookActivationFireId();
1155
+
1156
+ // Fan-out: every consumer must get its attempt even when an earlier
1157
+ // consumer fails. Remember the first error, run the rest, and only
1158
+ // surface ok when every consumer succeeded.
1159
+ const values: unknown[] = [];
1160
+ let firstError: Result<unknown, Error> | undefined;
1161
+ for (const consumerExecute of consumers) {
1162
+ const result = await consumerExecute(
1163
+ input,
1164
+ requestId,
1165
+ abortSignal,
1166
+ request,
1167
+ activationFireId
1168
+ );
1169
+ if (result.isErr()) {
1170
+ if (firstError === undefined) {
1171
+ firstError = result;
1172
+ }
1173
+ continue;
1174
+ }
1175
+ values.push(result.value);
1176
+ }
1177
+ if (firstError !== undefined) {
1178
+ return firstError;
1179
+ }
1180
+ return Result.ok(values);
1181
+ },
1182
+ };
1183
+ return { kind: 'merged', route: merged };
1184
+ };
1185
+
183
1186
  /** Register a route, checking for (path, method) collisions. */
184
1187
  const registerRoute = (
185
1188
  route: HttpRouteDefinition,
186
- seenRoutes: Map<string, string>,
1189
+ seenRoutes: Map<string, HttpRouteDefinition>,
187
1190
  routes: HttpRouteDefinition[]
188
1191
  ): Result<void, Error> => {
189
1192
  const key = routeKey(route);
190
- const existingId = seenRoutes.get(key);
191
- if (existingId !== undefined) {
1193
+ const existing = seenRoutes.get(key);
1194
+ if (existing !== undefined) {
1195
+ const outcome = mergeWebhookRoutes(existing, route);
1196
+ if (outcome.kind === 'merged') {
1197
+ seenRoutes.set(key, outcome.route);
1198
+ const routeIndex = routes.indexOf(existing);
1199
+ if (routeIndex !== -1) {
1200
+ routes[routeIndex] = outcome.route;
1201
+ }
1202
+ return Result.ok();
1203
+ }
1204
+ if (
1205
+ outcome.kind === 'verifier-mismatch' ||
1206
+ outcome.kind === 'parse-mismatch'
1207
+ ) {
1208
+ return Result.err(outcome.error);
1209
+ }
192
1210
  return Result.err(
193
1211
  new ValidationError(
194
- `HTTP route collision: trails "${existingId}" and "${route.trailId}" both derive ${route.method} ${route.path}`
1212
+ `HTTP route collision: trails "${existing.trailId}" and "${route.trailId}" both derive ${route.method} ${route.path}`
195
1213
  )
196
1214
  );
197
1215
  }
198
- seenRoutes.set(key, route.trailId);
1216
+ seenRoutes.set(key, route);
199
1217
  routes.push(route);
200
1218
  return Result.ok();
201
1219
  };
@@ -204,12 +1222,13 @@ const registerRoute = (
204
1222
  const accumulateRoutes = (
205
1223
  graph: Topo,
206
1224
  trails: Trail<unknown, unknown, unknown>[],
1225
+ webhookTrails: Trail<unknown, unknown, unknown>[],
207
1226
  basePath: string,
208
1227
  layers: readonly Layer[],
209
1228
  options: DeriveHttpRoutesOptions
210
1229
  ): Result<HttpRouteDefinition[], Error> => {
211
1230
  const routes: HttpRouteDefinition[] = [];
212
- const seenRoutes = new Map<string, string>();
1231
+ const seenRoutes = new Map<string, HttpRouteDefinition>();
213
1232
 
214
1233
  for (const trail of trails) {
215
1234
  const route = buildRoute(graph, trail, basePath, layers, options);
@@ -219,6 +1238,29 @@ const accumulateRoutes = (
219
1238
  }
220
1239
  }
221
1240
 
1241
+ for (const trail of webhookTrails) {
1242
+ for (const activation of trail.activationSources) {
1243
+ if (activation.source.kind !== 'webhook') {
1244
+ continue;
1245
+ }
1246
+ const route = buildWebhookRoute(
1247
+ graph,
1248
+ trail,
1249
+ activation,
1250
+ basePath,
1251
+ layers,
1252
+ options
1253
+ );
1254
+ if (route.isErr()) {
1255
+ return route;
1256
+ }
1257
+ const registered = registerRoute(route.value, seenRoutes, routes);
1258
+ if (registered.isErr()) {
1259
+ return registered;
1260
+ }
1261
+ }
1262
+ }
1263
+
222
1264
  return Result.ok(routes);
223
1265
  };
224
1266
 
@@ -237,16 +1279,26 @@ const accumulateRoutes = (
237
1279
  *
238
1280
  * Returns `Result.err(ValidationError)` if two trails derive the same
239
1281
  * (method, path) pair. Returns `Result.ok(routes)` on success.
1282
+ *
1283
+ * @example
1284
+ * ```ts
1285
+ * import { deriveHttpRoutes } from '@ontrails/http';
1286
+ *
1287
+ * const routes = deriveHttpRoutes(graph, { basePath: '/api' });
1288
+ * if (routes.isErr()) throw routes.error;
1289
+ *
1290
+ * for (const route of routes.value) {
1291
+ * console.log(`${route.method} ${route.path}`);
1292
+ * }
1293
+ * ```
240
1294
  */
241
1295
  export const deriveHttpRoutes = (
242
1296
  graph: Topo,
243
1297
  options: DeriveHttpRoutesOptions = {}
244
1298
  ): Result<HttpRouteDefinition[], Error> => {
245
- if (options.validate !== false) {
246
- const validated = validateEstablishedTopo(graph);
247
- if (validated.isErr()) {
248
- return Result.err(validated.error);
249
- }
1299
+ const validated = validateSurfaceTopo(graph, options);
1300
+ if (validated.isErr()) {
1301
+ return Result.err(validated.error);
250
1302
  }
251
1303
 
252
1304
  const basePath = (options.basePath ?? '').replace(/\/+$/, '');
@@ -254,6 +1306,7 @@ export const deriveHttpRoutes = (
254
1306
  return accumulateRoutes(
255
1307
  graph,
256
1308
  eligibleTrails(graph, options),
1309
+ eligibleWebhookTrails(graph, options),
257
1310
  basePath,
258
1311
  layers,
259
1312
  options