@ontrails/core 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/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- package/src/zod-wrappers.ts +72 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActivationSource,
|
|
3
|
+
ActivationSourceMeta,
|
|
4
|
+
ActivationSourceParse,
|
|
5
|
+
} from './activation-source.js';
|
|
6
|
+
import { InternalError, ValidationError } from './errors.js';
|
|
7
|
+
import type { AnyResource } from './resource.js';
|
|
8
|
+
import { Result } from './result.js';
|
|
9
|
+
import type { TrailContext } from './types.js';
|
|
10
|
+
|
|
11
|
+
export const webhookMethods = Object.freeze([
|
|
12
|
+
'DELETE',
|
|
13
|
+
'GET',
|
|
14
|
+
'PATCH',
|
|
15
|
+
'POST',
|
|
16
|
+
'PUT',
|
|
17
|
+
] as const);
|
|
18
|
+
|
|
19
|
+
export type WebhookMethod = (typeof webhookMethods)[number];
|
|
20
|
+
export type WebhookMethodInput = WebhookMethod | Lowercase<WebhookMethod>;
|
|
21
|
+
|
|
22
|
+
export type WebhookVerifyHeaders = Readonly<
|
|
23
|
+
Record<string, readonly string[] | string | undefined>
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
export interface WebhookVerifyRequest {
|
|
27
|
+
readonly body: ArrayBuffer | Uint8Array | string;
|
|
28
|
+
readonly headers: WebhookVerifyHeaders;
|
|
29
|
+
readonly method: string;
|
|
30
|
+
readonly path: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Verification callback for inbound webhook requests.
|
|
35
|
+
*
|
|
36
|
+
* When the source declares `resources`, surfaces call `verify` with a
|
|
37
|
+
* resource-capable context so signature checks can reach declared
|
|
38
|
+
* resources (e.g. a store holding per-endpoint secrets). Verifiers that
|
|
39
|
+
* only need the request may ignore the second parameter.
|
|
40
|
+
*/
|
|
41
|
+
export type WebhookVerify = (
|
|
42
|
+
request: WebhookVerifyRequest,
|
|
43
|
+
ctx?: TrailContext
|
|
44
|
+
) => Promise<Result<void, Error>> | Result<void, Error>;
|
|
45
|
+
|
|
46
|
+
export interface WebhookSpec<TOutput = unknown> {
|
|
47
|
+
/**
|
|
48
|
+
* Allowlist of header names delivered to the consumer trail. When set,
|
|
49
|
+
* the webhook envelope includes a `headers` map with the matching
|
|
50
|
+
* headers, lowercased. Headers outside the allowlist never reach the
|
|
51
|
+
* trail boundary.
|
|
52
|
+
*/
|
|
53
|
+
readonly headers?: readonly string[] | undefined;
|
|
54
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
55
|
+
readonly method?: WebhookMethodInput | undefined;
|
|
56
|
+
readonly parse: ActivationSourceParse<TOutput>;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path, optionally with dynamic segments (`/hooks/:endpoint`).
|
|
59
|
+
* Segment values are delivered as fields of the webhook envelope under
|
|
60
|
+
* their segment names.
|
|
61
|
+
*/
|
|
62
|
+
readonly path: string;
|
|
63
|
+
readonly payload?: ActivationSource['payload'] | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Deliver the raw request body text to the consumer trail as a
|
|
66
|
+
* `rawBody` envelope field. With `rawBody`, a non-JSON body no longer
|
|
67
|
+
* fails at the surface — the trail owns payload interpretation (e.g.
|
|
68
|
+
* HMAC verification over exact bytes).
|
|
69
|
+
*/
|
|
70
|
+
readonly rawBody?: boolean | undefined;
|
|
71
|
+
/** Resources the `verify` callback may access through its context. */
|
|
72
|
+
readonly resources?: readonly AnyResource[] | undefined;
|
|
73
|
+
readonly verify?: WebhookVerify | undefined;
|
|
74
|
+
/** Reserved for future webhook-specific design; trail versioning is trail-only. */
|
|
75
|
+
readonly version?: never;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WebhookSource<TOutput = unknown> extends ActivationSource {
|
|
79
|
+
readonly kind: 'webhook';
|
|
80
|
+
readonly headers?: readonly string[] | undefined;
|
|
81
|
+
readonly meta?: ActivationSourceMeta | undefined;
|
|
82
|
+
readonly method: WebhookMethod;
|
|
83
|
+
readonly parse: ActivationSourceParse<TOutput>;
|
|
84
|
+
readonly path: string;
|
|
85
|
+
/** Dynamic segment names parsed from `path`, in order. Empty for static paths. */
|
|
86
|
+
readonly pathParams: readonly string[];
|
|
87
|
+
readonly payload?: ActivationSource['payload'] | undefined;
|
|
88
|
+
readonly rawBody?: boolean | undefined;
|
|
89
|
+
readonly resources?: readonly AnyResource[] | undefined;
|
|
90
|
+
readonly verify?: WebhookVerify | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WebhookValidationIssue {
|
|
94
|
+
readonly field: 'method' | 'parse' | 'path' | 'verify';
|
|
95
|
+
readonly message: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFAULT_WEBHOOK_METHOD = 'POST' as const;
|
|
99
|
+
|
|
100
|
+
const normalizeMethod = (
|
|
101
|
+
method: WebhookMethodInput | string | undefined
|
|
102
|
+
): string => (method ?? DEFAULT_WEBHOOK_METHOD).trim().toUpperCase();
|
|
103
|
+
|
|
104
|
+
const normalizePath = (path: string): string => path.trim();
|
|
105
|
+
|
|
106
|
+
const WEBHOOK_PATH_PARAM_PATTERN = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
107
|
+
|
|
108
|
+
/** Envelope field names a path segment must not shadow. */
|
|
109
|
+
const RESERVED_ENVELOPE_FIELDS = new Set(['body', 'headers', 'rawBody']);
|
|
110
|
+
|
|
111
|
+
const pathSegments = (path: string): readonly string[] =>
|
|
112
|
+
normalizePath(path).split('/').slice(1);
|
|
113
|
+
|
|
114
|
+
const isParamSegment = (segment: string): boolean => segment.startsWith(':');
|
|
115
|
+
|
|
116
|
+
/** Decode a path segment, keeping the raw text when the encoding is malformed. */
|
|
117
|
+
const decodeSegment = (segment: string): string => {
|
|
118
|
+
try {
|
|
119
|
+
return decodeURIComponent(segment);
|
|
120
|
+
} catch {
|
|
121
|
+
return segment;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse the dynamic segment names out of a webhook path pattern.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* parseWebhookPathParams('/hooks/:endpoint'); // ['endpoint']
|
|
131
|
+
* parseWebhookPathParams('/webhooks/payment'); // []
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export const parseWebhookPathParams = (path: string): readonly string[] =>
|
|
135
|
+
pathSegments(path)
|
|
136
|
+
.filter((segment) => isParamSegment(segment))
|
|
137
|
+
.map((segment) => segment.slice(1));
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Match a concrete request path against a webhook path pattern.
|
|
141
|
+
*
|
|
142
|
+
* Returns the captured segment values keyed by segment name, or
|
|
143
|
+
* `undefined` when the path does not match. Static patterns match only
|
|
144
|
+
* themselves and capture nothing.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* matchWebhookPath('/hooks/:endpoint', '/hooks/github');
|
|
149
|
+
* // { endpoint: 'github' }
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export const matchWebhookPath = (
|
|
153
|
+
pattern: string,
|
|
154
|
+
path: string
|
|
155
|
+
): Readonly<Record<string, string>> | undefined => {
|
|
156
|
+
const patternSegments = pathSegments(pattern);
|
|
157
|
+
const actualSegments = path.split('/').slice(1);
|
|
158
|
+
if (patternSegments.length !== actualSegments.length) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const params: Record<string, string> = {};
|
|
163
|
+
for (const [index, patternSegment] of patternSegments.entries()) {
|
|
164
|
+
const actual = actualSegments[index] ?? '';
|
|
165
|
+
if (isParamSegment(patternSegment)) {
|
|
166
|
+
if (actual.length === 0) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
params[patternSegment.slice(1)] = decodeSegment(actual);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (patternSegment !== actual) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return params;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True when two webhook path patterns can both match one concrete path.
|
|
181
|
+
*
|
|
182
|
+
* Segment-wise: two literals overlap only when equal; a dynamic segment
|
|
183
|
+
* overlaps anything. Used by governance to extend route-collision
|
|
184
|
+
* detection past exact-path equality.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* webhookPathPatternsOverlap('/hooks/:a', '/hooks/github'); // true
|
|
189
|
+
* webhookPathPatternsOverlap('/hooks/:a', '/api/:b'); // false
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
export const webhookPathPatternsOverlap = (
|
|
193
|
+
left: string,
|
|
194
|
+
right: string
|
|
195
|
+
): boolean => {
|
|
196
|
+
const leftSegments = pathSegments(left);
|
|
197
|
+
const rightSegments = pathSegments(right);
|
|
198
|
+
if (leftSegments.length !== rightSegments.length) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return leftSegments.every((leftSegment, index) => {
|
|
202
|
+
const rightSegment = rightSegments[index] ?? '';
|
|
203
|
+
return (
|
|
204
|
+
isParamSegment(leftSegment) ||
|
|
205
|
+
isParamSegment(rightSegment) ||
|
|
206
|
+
leftSegment === rightSegment
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const isWebhookMethod = (method: string): method is WebhookMethod =>
|
|
212
|
+
(webhookMethods as readonly string[]).includes(method);
|
|
213
|
+
|
|
214
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
215
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
216
|
+
|
|
217
|
+
const isZodSchema = (value: unknown): boolean =>
|
|
218
|
+
isObjectRecord(value) && typeof value['safeParse'] === 'function';
|
|
219
|
+
|
|
220
|
+
const validateMethod = (
|
|
221
|
+
method: WebhookMethodInput | string | undefined
|
|
222
|
+
): WebhookValidationIssue[] => {
|
|
223
|
+
const normalized = normalizeMethod(method);
|
|
224
|
+
return isWebhookMethod(normalized)
|
|
225
|
+
? []
|
|
226
|
+
: [
|
|
227
|
+
{
|
|
228
|
+
field: 'method',
|
|
229
|
+
message: `Webhook method must be one of ${webhookMethods.join(', ')}`,
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const validatePath = (path: unknown): WebhookValidationIssue[] => {
|
|
235
|
+
if (typeof path !== 'string' || path.trim().length === 0) {
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
field: 'path',
|
|
239
|
+
message: 'Webhook path must be a non-empty absolute path',
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const normalized = normalizePath(path);
|
|
245
|
+
if (!normalized.startsWith('/')) {
|
|
246
|
+
return [
|
|
247
|
+
{
|
|
248
|
+
field: 'path',
|
|
249
|
+
message: 'Webhook path must start with "/"',
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const issues: WebhookValidationIssue[] = [];
|
|
255
|
+
for (const segment of pathSegments(normalized)) {
|
|
256
|
+
if (isParamSegment(segment) && !WEBHOOK_PATH_PARAM_PATTERN.test(segment)) {
|
|
257
|
+
issues.push({
|
|
258
|
+
field: 'path',
|
|
259
|
+
message: `Webhook path segment "${segment}" must match :name with a letter or underscore first`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const params = parseWebhookPathParams(normalized);
|
|
265
|
+
if (new Set(params).size !== params.length) {
|
|
266
|
+
issues.push({
|
|
267
|
+
field: 'path',
|
|
268
|
+
message: 'Webhook path segments must use unique names',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const param of params) {
|
|
272
|
+
if (RESERVED_ENVELOPE_FIELDS.has(param)) {
|
|
273
|
+
issues.push({
|
|
274
|
+
field: 'path',
|
|
275
|
+
message: `Webhook path segment ":${param}" collides with the reserved envelope field "${param}"`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return issues;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const validateRequiredParse = (parse: unknown): WebhookValidationIssue[] =>
|
|
284
|
+
parse === undefined
|
|
285
|
+
? [
|
|
286
|
+
{
|
|
287
|
+
field: 'parse',
|
|
288
|
+
message: 'Webhook sources must define parse',
|
|
289
|
+
},
|
|
290
|
+
]
|
|
291
|
+
: [];
|
|
292
|
+
|
|
293
|
+
const validateParseShape = (parse: unknown): WebhookValidationIssue[] => {
|
|
294
|
+
if (parse === undefined || isZodSchema(parse)) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
if (isObjectRecord(parse) && isZodSchema(parse['output'])) {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
field: 'parse',
|
|
303
|
+
message: 'Webhook parse must be a Zod schema or define parse.output',
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const validateVerify = (verify: unknown): WebhookValidationIssue[] =>
|
|
309
|
+
verify === undefined || typeof verify === 'function'
|
|
310
|
+
? []
|
|
311
|
+
: [
|
|
312
|
+
{
|
|
313
|
+
field: 'verify',
|
|
314
|
+
message: 'Webhook verify must be a function when provided',
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const webhookIssuesMessage = (
|
|
319
|
+
id: string,
|
|
320
|
+
issues: readonly WebhookValidationIssue[]
|
|
321
|
+
): string =>
|
|
322
|
+
`webhook("${id}") is invalid: ${issues.map((issue) => `${issue.field}: ${issue.message}`).join('; ')}`;
|
|
323
|
+
|
|
324
|
+
const assertWebhookSpec = <TOutput>(
|
|
325
|
+
id: string,
|
|
326
|
+
spec: WebhookSpec<TOutput>
|
|
327
|
+
): {
|
|
328
|
+
readonly method: WebhookMethod;
|
|
329
|
+
readonly path: string;
|
|
330
|
+
} => {
|
|
331
|
+
const issues = [
|
|
332
|
+
...validateMethod(spec.method),
|
|
333
|
+
...validatePath(spec.path),
|
|
334
|
+
...validateRequiredParse(spec.parse),
|
|
335
|
+
...validateParseShape(spec.parse),
|
|
336
|
+
...validateVerify(spec.verify),
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
if (issues.length > 0) {
|
|
340
|
+
throw new ValidationError(webhookIssuesMessage(id, issues), {
|
|
341
|
+
context: { issues },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
method: normalizeMethod(spec.method) as WebhookMethod,
|
|
347
|
+
path: normalizePath(spec.path),
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export const validateWebhookSource = (
|
|
352
|
+
source: ActivationSource
|
|
353
|
+
): readonly WebhookValidationIssue[] => {
|
|
354
|
+
if (source.kind !== 'webhook') {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return [
|
|
359
|
+
...validateMethod(source.method),
|
|
360
|
+
...validatePath(source.path),
|
|
361
|
+
...validateRequiredParse(source.parse),
|
|
362
|
+
...validateParseShape(source.parse),
|
|
363
|
+
...validateVerify(source.verify),
|
|
364
|
+
];
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const errorFromUnknown = (error: unknown): Error =>
|
|
368
|
+
error instanceof Error ? error : new Error(String(error));
|
|
369
|
+
|
|
370
|
+
export const getWebhookHeaders = (
|
|
371
|
+
request: Pick<WebhookVerifyRequest, 'headers'>,
|
|
372
|
+
name: string
|
|
373
|
+
): readonly string[] => {
|
|
374
|
+
const normalized = name.toLowerCase();
|
|
375
|
+
const matches: string[] = [];
|
|
376
|
+
for (const [headerName, value] of Object.entries(request.headers)) {
|
|
377
|
+
if (headerName.toLowerCase() !== normalized) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (value === undefined) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (typeof value === 'string') {
|
|
384
|
+
matches.push(value);
|
|
385
|
+
} else {
|
|
386
|
+
matches.push(...value);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return matches;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export const getWebhookHeader = (
|
|
393
|
+
request: Pick<WebhookVerifyRequest, 'headers'>,
|
|
394
|
+
name: string
|
|
395
|
+
): string | undefined => {
|
|
396
|
+
const [first] = getWebhookHeaders(request, name);
|
|
397
|
+
return first;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export const verifyWebhookRequest = async (
|
|
401
|
+
source: Pick<WebhookSource, 'id' | 'verify'>,
|
|
402
|
+
request: WebhookVerifyRequest,
|
|
403
|
+
ctx?: TrailContext
|
|
404
|
+
): Promise<Result<void, Error>> => {
|
|
405
|
+
if (source.verify === undefined) {
|
|
406
|
+
return Result.ok();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
return await source.verify(request, ctx);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const cause = errorFromUnknown(error);
|
|
413
|
+
return Result.err(
|
|
414
|
+
new InternalError(`Webhook source "${source.id}" verification threw`, {
|
|
415
|
+
cause,
|
|
416
|
+
})
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
export function webhook<TOutput>(
|
|
422
|
+
id: string,
|
|
423
|
+
spec: WebhookSpec<TOutput>
|
|
424
|
+
): WebhookSource<TOutput>;
|
|
425
|
+
export function webhook<TOutput>(
|
|
426
|
+
spec: WebhookSpec<TOutput> & { readonly id: string }
|
|
427
|
+
): WebhookSource<TOutput>;
|
|
428
|
+
export function webhook<TOutput>(
|
|
429
|
+
idOrSpec: string | (WebhookSpec<TOutput> & { readonly id: string }),
|
|
430
|
+
maybeSpec?: WebhookSpec<TOutput>
|
|
431
|
+
): WebhookSource<TOutput> {
|
|
432
|
+
const id = typeof idOrSpec === 'string' ? idOrSpec : idOrSpec.id;
|
|
433
|
+
// oxlint-disable-next-line no-non-null-assertion -- overload guarantees maybeSpec when idOrSpec is string
|
|
434
|
+
const spec = typeof idOrSpec === 'string' ? maybeSpec! : idOrSpec;
|
|
435
|
+
const normalized = assertWebhookSpec(id, spec);
|
|
436
|
+
|
|
437
|
+
return Object.freeze({
|
|
438
|
+
id,
|
|
439
|
+
kind: 'webhook' as const,
|
|
440
|
+
method: normalized.method,
|
|
441
|
+
parse: spec.parse,
|
|
442
|
+
path: normalized.path,
|
|
443
|
+
pathParams: Object.freeze([...parseWebhookPathParams(normalized.path)]),
|
|
444
|
+
...(spec.headers === undefined
|
|
445
|
+
? {}
|
|
446
|
+
: {
|
|
447
|
+
headers: Object.freeze(
|
|
448
|
+
spec.headers.map((name) => name.toLowerCase())
|
|
449
|
+
),
|
|
450
|
+
}),
|
|
451
|
+
...(spec.meta === undefined
|
|
452
|
+
? {}
|
|
453
|
+
: { meta: Object.freeze({ ...spec.meta }) }),
|
|
454
|
+
...(spec.payload === undefined ? {} : { payload: spec.payload }),
|
|
455
|
+
...(spec.rawBody === undefined ? {} : { rawBody: spec.rawBody }),
|
|
456
|
+
...(spec.resources === undefined
|
|
457
|
+
? {}
|
|
458
|
+
: { resources: Object.freeze([...spec.resources]) }),
|
|
459
|
+
...(spec.verify === undefined ? {} : { verify: spec.verify }),
|
|
460
|
+
});
|
|
461
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace detection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Walks the filesystem to find monorepo workspace roots and provides
|
|
5
|
+
* helpers for working with paths relative to a workspace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NotFoundError } from './errors.js';
|
|
9
|
+
import { Result } from './result.js';
|
|
10
|
+
// Workspace discovery is a tooling path: the node builtins load lazily at
|
|
11
|
+
// first use so the core barrel's module graph stays execution-portable on
|
|
12
|
+
// runtimes without node: builtins (TRL-1198).
|
|
13
|
+
import { loadRuntimeBuiltin } from './runtime-builtins.js';
|
|
14
|
+
|
|
15
|
+
const fs = () => loadRuntimeBuiltin('node:fs');
|
|
16
|
+
const nodePath = () => loadRuntimeBuiltin('node:path');
|
|
17
|
+
|
|
18
|
+
export interface WorkspaceRootManifest {
|
|
19
|
+
readonly workspaces?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkspacePackage<
|
|
23
|
+
Manifest extends object = Record<string, unknown>,
|
|
24
|
+
> {
|
|
25
|
+
readonly manifest: Manifest;
|
|
26
|
+
readonly packageJsonPath: string;
|
|
27
|
+
readonly packageRoot: string;
|
|
28
|
+
readonly workspacePath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
32
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
const normalizePath = (path: string): string => path.replaceAll('\\', '/');
|
|
35
|
+
|
|
36
|
+
const normalizeRealPath = (path: string): string => {
|
|
37
|
+
try {
|
|
38
|
+
return normalizePath(fs().realpathSync(path));
|
|
39
|
+
} catch {
|
|
40
|
+
return normalizePath(nodePath().resolve(path));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readJsonSync = <T>(path: string): T | undefined => {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs().readFileSync(path, 'utf8')) as T;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Check if a directory has a package.json with a `workspaces` field. */
|
|
53
|
+
const hasWorkspacesField = (dir: string): boolean => {
|
|
54
|
+
const pkg = readJsonSync<unknown>(nodePath().join(dir, 'package.json'));
|
|
55
|
+
return typeof pkg === 'object' && pkg !== null && 'workspaces' in pkg;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* List workspace patterns from a root package manifest.
|
|
60
|
+
*
|
|
61
|
+
* Supports npm/Bun's array form and Yarn-style `{ packages: [] }` form.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* listWorkspacePatterns({ workspaces: ['packages/*'] });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export const listWorkspacePatterns = (
|
|
69
|
+
manifest: WorkspaceRootManifest | undefined
|
|
70
|
+
): readonly string[] => {
|
|
71
|
+
const { workspaces } = manifest ?? {};
|
|
72
|
+
if (Array.isArray(workspaces)) {
|
|
73
|
+
return workspaces.filter(
|
|
74
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const packages = isRecord(workspaces) ? workspaces['packages'] : undefined;
|
|
79
|
+
return Array.isArray(packages)
|
|
80
|
+
? packages.filter(
|
|
81
|
+
(pattern): pattern is string => typeof pattern === 'string'
|
|
82
|
+
)
|
|
83
|
+
: [];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const workspaceDirsForPattern = (
|
|
87
|
+
rootDir: string,
|
|
88
|
+
pattern: string
|
|
89
|
+
): readonly string[] => {
|
|
90
|
+
if (!pattern.endsWith('/*')) {
|
|
91
|
+
const workspaceDir = nodePath().join(rootDir, pattern);
|
|
92
|
+
return fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
|
|
93
|
+
? [workspaceDir]
|
|
94
|
+
: [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const groupDir = nodePath().join(rootDir, pattern.slice(0, -2));
|
|
98
|
+
if (!fs().existsSync(groupDir)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return fs()
|
|
103
|
+
.readdirSync(groupDir, { withFileTypes: true })
|
|
104
|
+
.filter((entry) => entry.isDirectory())
|
|
105
|
+
.map((entry) => nodePath().join(groupDir, entry.name))
|
|
106
|
+
.filter((workspaceDir) =>
|
|
107
|
+
fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
|
|
108
|
+
)
|
|
109
|
+
.toSorted();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List package directories matched by workspace patterns.
|
|
114
|
+
*
|
|
115
|
+
* Only directories containing `package.json` are returned.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* listWorkspacePackageDirs('/repo', ['packages/*']);
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export const listWorkspacePackageDirs = (
|
|
123
|
+
rootDir: string,
|
|
124
|
+
patterns: readonly string[]
|
|
125
|
+
): readonly string[] =>
|
|
126
|
+
patterns.flatMap((pattern) => workspaceDirsForPattern(rootDir, pattern));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* List workspace package manifests from a workspace root.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const packages = listWorkspacePackages('/repo');
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export const listWorkspacePackages = <
|
|
137
|
+
Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
|
|
138
|
+
>(
|
|
139
|
+
rootDir: string
|
|
140
|
+
): readonly WorkspacePackage<Manifest>[] => {
|
|
141
|
+
const normalizedRoot = normalizeRealPath(rootDir);
|
|
142
|
+
const rootManifest = readJsonSync<WorkspaceRootManifest>(
|
|
143
|
+
nodePath().join(normalizedRoot, 'package.json')
|
|
144
|
+
);
|
|
145
|
+
const packages: WorkspacePackage<Manifest>[] = [];
|
|
146
|
+
|
|
147
|
+
for (const workspaceDir of listWorkspacePackageDirs(
|
|
148
|
+
normalizedRoot,
|
|
149
|
+
listWorkspacePatterns(rootManifest)
|
|
150
|
+
)) {
|
|
151
|
+
const packageJsonPath = nodePath().join(workspaceDir, 'package.json');
|
|
152
|
+
const manifest = readJsonSync<Manifest>(packageJsonPath);
|
|
153
|
+
if (!manifest || typeof manifest.name !== 'string') {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const packageRoot = normalizeRealPath(nodePath().dirname(packageJsonPath));
|
|
158
|
+
packages.push({
|
|
159
|
+
manifest,
|
|
160
|
+
packageJsonPath: normalizeRealPath(packageJsonPath),
|
|
161
|
+
packageRoot,
|
|
162
|
+
workspacePath: normalizePath(
|
|
163
|
+
nodePath().relative(normalizedRoot, packageRoot)
|
|
164
|
+
),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return packages.toSorted((left, right) =>
|
|
169
|
+
left.workspacePath.localeCompare(right.workspacePath)
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find a workspace package by its package name.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* const workspace = findWorkspacePackage('/repo', '@ontrails/core');
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export const findWorkspacePackage = <
|
|
182
|
+
Manifest extends { readonly name?: unknown } = { readonly name?: unknown },
|
|
183
|
+
>(
|
|
184
|
+
rootDir: string,
|
|
185
|
+
packageName: string
|
|
186
|
+
): WorkspacePackage<Manifest> | undefined =>
|
|
187
|
+
listWorkspacePackages<Manifest>(rootDir).find(
|
|
188
|
+
(workspacePackage) => workspacePackage.manifest.name === packageName
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Walks up from `startDir` (defaults to `process.cwd()`) looking for a
|
|
193
|
+
* `package.json` that contains a `"workspaces"` field.
|
|
194
|
+
*
|
|
195
|
+
* Returns the directory path of the workspace root on success, or a
|
|
196
|
+
* `NotFoundError` if no workspace root is found.
|
|
197
|
+
*/
|
|
198
|
+
export const findWorkspaceRoot = async (
|
|
199
|
+
startDir?: string
|
|
200
|
+
): Promise<Result<string, NotFoundError>> => {
|
|
201
|
+
let current = nodePath().resolve(startDir ?? process.cwd());
|
|
202
|
+
|
|
203
|
+
// eslint-disable-next-line no-constant-condition
|
|
204
|
+
while (true) {
|
|
205
|
+
if (hasWorkspacesField(current)) {
|
|
206
|
+
return Result.ok(current);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const parent = nodePath().dirname(current);
|
|
210
|
+
|
|
211
|
+
if (parent === current) {
|
|
212
|
+
return Result.err(
|
|
213
|
+
new NotFoundError(
|
|
214
|
+
`No workspace root found from "${startDir ?? process.cwd()}"`
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
current = parent;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Returns `true` if `filePath` is inside `workspaceRoot`.
|
|
225
|
+
*/
|
|
226
|
+
export const isInsideWorkspace = (
|
|
227
|
+
filePath: string,
|
|
228
|
+
workspaceRoot: string
|
|
229
|
+
): boolean => {
|
|
230
|
+
const { isAbsolute, relative, resolve } = nodePath();
|
|
231
|
+
const rel = relative(resolve(workspaceRoot), resolve(filePath));
|
|
232
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Returns the relative path from `workspaceRoot` to `filePath`.
|
|
237
|
+
*/
|
|
238
|
+
export const deriveRelativePath = (
|
|
239
|
+
filePath: string,
|
|
240
|
+
workspaceRoot: string
|
|
241
|
+
): string => {
|
|
242
|
+
const { relative, resolve } = nodePath();
|
|
243
|
+
return relative(resolve(workspaceRoot), resolve(filePath));
|
|
244
|
+
};
|