@ontrails/http 1.0.0-beta.15 → 1.0.0-beta.16
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/CHANGELOG.md +47 -0
- package/README.md +18 -5
- package/package.json +10 -2
- package/src/build.ts +1095 -54
- package/src/index.ts +16 -2
- package/src/method.ts +24 -0
- package/src/openapi.ts +357 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/build.d.ts +0 -57
- package/dist/build.d.ts.map +0 -1
- package/dist/build.js +0 -130
- package/dist/build.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +0 -1
- package/src/__tests__/build.test.ts +0 -610
- package/src/__tests__/derive.test.ts +0 -31
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
66
|
+
readonly resolvePermit?: ResolveHttpPermit | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type HttpHeaderSource =
|
|
70
|
+
| Headers
|
|
71
|
+
| Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
72
|
+
|
|
73
|
+
export interface HttpExecutionContext {
|
|
74
|
+
readonly headers?: HttpHeaderSource | undefined;
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
export
|
|
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
|
+
}
|
|
49
83
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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:
|
|
135
|
-
|
|
660
|
+
ctx: withHttpSurface(requestId, layers),
|
|
661
|
+
...(Object.keys(layerInputs).length === 0 ? {} : { layerInputs }),
|
|
662
|
+
...(permit === undefined ? {} : { permit }),
|
|
663
|
+
resources: options.resources,
|
|
664
|
+
surfaceLayers: layers,
|
|
665
|
+
topo: graph,
|
|
666
|
+
topoLayers: graph.layers,
|
|
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 }),
|
|
136
745
|
resources: options.resources,
|
|
746
|
+
surfaceLayers: layers,
|
|
137
747
|
topo: graph,
|
|
748
|
+
topoLayers: graph.layers,
|
|
138
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(
|
|
167
|
-
|
|
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,
|
|
1189
|
+
seenRoutes: Map<string, HttpRouteDefinition>,
|
|
187
1190
|
routes: HttpRouteDefinition[]
|
|
188
1191
|
): Result<void, Error> => {
|
|
189
1192
|
const key = routeKey(route);
|
|
190
|
-
const
|
|
191
|
-
if (
|
|
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 "${
|
|
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
|
|
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,
|
|
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
|
|
|
@@ -242,11 +1284,9 @@ export const deriveHttpRoutes = (
|
|
|
242
1284
|
graph: Topo,
|
|
243
1285
|
options: DeriveHttpRoutesOptions = {}
|
|
244
1286
|
): Result<HttpRouteDefinition[], Error> => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return Result.err(validated.error);
|
|
249
|
-
}
|
|
1287
|
+
const validated = validateSurfaceTopo(graph, options);
|
|
1288
|
+
if (validated.isErr()) {
|
|
1289
|
+
return Result.err(validated.error);
|
|
250
1290
|
}
|
|
251
1291
|
|
|
252
1292
|
const basePath = (options.basePath ?? '').replace(/\/+$/, '');
|
|
@@ -254,6 +1294,7 @@ export const deriveHttpRoutes = (
|
|
|
254
1294
|
return accumulateRoutes(
|
|
255
1295
|
graph,
|
|
256
1296
|
eligibleTrails(graph, options),
|
|
1297
|
+
eligibleWebhookTrails(graph, options),
|
|
257
1298
|
basePath,
|
|
258
1299
|
layers,
|
|
259
1300
|
options
|