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