@ontrails/core 1.0.0-beta.32 → 1.0.0-beta.39

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/webhook.ts CHANGED
@@ -4,7 +4,9 @@ import type {
4
4
  ActivationSourceParse,
5
5
  } from './activation-source.js';
6
6
  import { InternalError, ValidationError } from './errors.js';
7
+ import type { AnyResource } from './resource.js';
7
8
  import { Result } from './result.js';
9
+ import type { TrailContext } from './types.js';
8
10
 
9
11
  export const webhookMethods = Object.freeze([
10
12
  'DELETE',
@@ -28,16 +30,46 @@ export interface WebhookVerifyRequest {
28
30
  readonly path: string;
29
31
  }
30
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
+ */
31
41
  export type WebhookVerify = (
32
- request: WebhookVerifyRequest
42
+ request: WebhookVerifyRequest,
43
+ ctx?: TrailContext
33
44
  ) => Promise<Result<void, Error>> | Result<void, Error>;
34
45
 
35
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;
36
54
  readonly meta?: ActivationSourceMeta | undefined;
37
55
  readonly method?: WebhookMethodInput | undefined;
38
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
+ */
39
62
  readonly path: string;
40
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;
41
73
  readonly verify?: WebhookVerify | undefined;
42
74
  /** Reserved for future webhook-specific design; trail versioning is trail-only. */
43
75
  readonly version?: never;
@@ -45,11 +77,16 @@ export interface WebhookSpec<TOutput = unknown> {
45
77
 
46
78
  export interface WebhookSource<TOutput = unknown> extends ActivationSource {
47
79
  readonly kind: 'webhook';
80
+ readonly headers?: readonly string[] | undefined;
48
81
  readonly meta?: ActivationSourceMeta | undefined;
49
82
  readonly method: WebhookMethod;
50
83
  readonly parse: ActivationSourceParse<TOutput>;
51
84
  readonly path: string;
85
+ /** Dynamic segment names parsed from `path`, in order. Empty for static paths. */
86
+ readonly pathParams: readonly string[];
52
87
  readonly payload?: ActivationSource['payload'] | undefined;
88
+ readonly rawBody?: boolean | undefined;
89
+ readonly resources?: readonly AnyResource[] | undefined;
53
90
  readonly verify?: WebhookVerify | undefined;
54
91
  }
55
92
 
@@ -66,6 +103,111 @@ const normalizeMethod = (
66
103
 
67
104
  const normalizePath = (path: string): string => path.trim();
68
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
+
69
211
  const isWebhookMethod = (method: string): method is WebhookMethod =>
70
212
  (webhookMethods as readonly string[]).includes(method);
71
213
 
@@ -100,14 +242,42 @@ const validatePath = (path: unknown): WebhookValidationIssue[] => {
100
242
  }
101
243
 
102
244
  const normalized = normalizePath(path);
103
- return normalized.startsWith('/')
104
- ? []
105
- : [
106
- {
107
- field: 'path',
108
- message: 'Webhook path must start with "/"',
109
- },
110
- ];
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;
111
281
  };
112
282
 
113
283
  const validateRequiredParse = (parse: unknown): WebhookValidationIssue[] =>
@@ -229,14 +399,15 @@ export const getWebhookHeader = (
229
399
 
230
400
  export const verifyWebhookRequest = async (
231
401
  source: Pick<WebhookSource, 'id' | 'verify'>,
232
- request: WebhookVerifyRequest
402
+ request: WebhookVerifyRequest,
403
+ ctx?: TrailContext
233
404
  ): Promise<Result<void, Error>> => {
234
405
  if (source.verify === undefined) {
235
406
  return Result.ok();
236
407
  }
237
408
 
238
409
  try {
239
- return await source.verify(request);
410
+ return await source.verify(request, ctx);
240
411
  } catch (error) {
241
412
  const cause = errorFromUnknown(error);
242
413
  return Result.err(
@@ -269,10 +440,22 @@ export function webhook<TOutput>(
269
440
  method: normalized.method,
270
441
  parse: spec.parse,
271
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
+ }),
272
451
  ...(spec.meta === undefined
273
452
  ? {}
274
453
  : { meta: Object.freeze({ ...spec.meta }) }),
275
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]) }),
276
459
  ...(spec.verify === undefined ? {} : { verify: spec.verify }),
277
460
  });
278
461
  }
package/src/workspace.ts CHANGED
@@ -5,11 +5,15 @@
5
5
  * helpers for working with paths relative to a workspace.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
9
- import { resolve, relative, dirname, join, isAbsolute } from 'node:path';
10
-
11
8
  import { NotFoundError } from './errors.js';
12
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');
13
17
 
14
18
  export interface WorkspaceRootManifest {
15
19
  readonly workspaces?: unknown;
@@ -31,33 +35,24 @@ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
31
35
 
32
36
  const normalizeRealPath = (path: string): string => {
33
37
  try {
34
- return normalizePath(realpathSync(path));
38
+ return normalizePath(fs().realpathSync(path));
35
39
  } catch {
36
- return normalizePath(resolve(path));
40
+ return normalizePath(nodePath().resolve(path));
37
41
  }
38
42
  };
39
43
 
40
44
  const readJsonSync = <T>(path: string): T | undefined => {
41
45
  try {
42
- return JSON.parse(readFileSync(path, 'utf8')) as T;
46
+ return JSON.parse(fs().readFileSync(path, 'utf8')) as T;
43
47
  } catch {
44
48
  return undefined;
45
49
  }
46
50
  };
47
51
 
48
52
  /** Check if a directory has a package.json with a `workspaces` field. */
49
- const hasWorkspacesField = async (dir: string): Promise<boolean> => {
50
- const pkgPath = join(dir, 'package.json');
51
- const file = Bun.file(pkgPath);
52
- if (!(await file.exists())) {
53
- return false;
54
- }
55
- try {
56
- const pkg: unknown = await file.json();
57
- return typeof pkg === 'object' && pkg !== null && 'workspaces' in pkg;
58
- } catch {
59
- return false;
60
- }
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;
61
56
  };
62
57
 
63
58
  /**
@@ -93,19 +88,24 @@ const workspaceDirsForPattern = (
93
88
  pattern: string
94
89
  ): readonly string[] => {
95
90
  if (!pattern.endsWith('/*')) {
96
- const workspaceDir = join(rootDir, pattern);
97
- return existsSync(join(workspaceDir, 'package.json')) ? [workspaceDir] : [];
91
+ const workspaceDir = nodePath().join(rootDir, pattern);
92
+ return fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
93
+ ? [workspaceDir]
94
+ : [];
98
95
  }
99
96
 
100
- const groupDir = join(rootDir, pattern.slice(0, -2));
101
- if (!existsSync(groupDir)) {
97
+ const groupDir = nodePath().join(rootDir, pattern.slice(0, -2));
98
+ if (!fs().existsSync(groupDir)) {
102
99
  return [];
103
100
  }
104
101
 
105
- return readdirSync(groupDir, { withFileTypes: true })
102
+ return fs()
103
+ .readdirSync(groupDir, { withFileTypes: true })
106
104
  .filter((entry) => entry.isDirectory())
107
- .map((entry) => join(groupDir, entry.name))
108
- .filter((workspaceDir) => existsSync(join(workspaceDir, 'package.json')))
105
+ .map((entry) => nodePath().join(groupDir, entry.name))
106
+ .filter((workspaceDir) =>
107
+ fs().existsSync(nodePath().join(workspaceDir, 'package.json'))
108
+ )
109
109
  .toSorted();
110
110
  };
111
111
 
@@ -140,7 +140,7 @@ export const listWorkspacePackages = <
140
140
  ): readonly WorkspacePackage<Manifest>[] => {
141
141
  const normalizedRoot = normalizeRealPath(rootDir);
142
142
  const rootManifest = readJsonSync<WorkspaceRootManifest>(
143
- join(normalizedRoot, 'package.json')
143
+ nodePath().join(normalizedRoot, 'package.json')
144
144
  );
145
145
  const packages: WorkspacePackage<Manifest>[] = [];
146
146
 
@@ -148,18 +148,20 @@ export const listWorkspacePackages = <
148
148
  normalizedRoot,
149
149
  listWorkspacePatterns(rootManifest)
150
150
  )) {
151
- const packageJsonPath = join(workspaceDir, 'package.json');
151
+ const packageJsonPath = nodePath().join(workspaceDir, 'package.json');
152
152
  const manifest = readJsonSync<Manifest>(packageJsonPath);
153
153
  if (!manifest || typeof manifest.name !== 'string') {
154
154
  continue;
155
155
  }
156
156
 
157
- const packageRoot = normalizeRealPath(dirname(packageJsonPath));
157
+ const packageRoot = normalizeRealPath(nodePath().dirname(packageJsonPath));
158
158
  packages.push({
159
159
  manifest,
160
160
  packageJsonPath: normalizeRealPath(packageJsonPath),
161
161
  packageRoot,
162
- workspacePath: normalizePath(relative(normalizedRoot, packageRoot)),
162
+ workspacePath: normalizePath(
163
+ nodePath().relative(normalizedRoot, packageRoot)
164
+ ),
163
165
  });
164
166
  }
165
167
 
@@ -196,15 +198,15 @@ export const findWorkspacePackage = <
196
198
  export const findWorkspaceRoot = async (
197
199
  startDir?: string
198
200
  ): Promise<Result<string, NotFoundError>> => {
199
- let current = resolve(startDir ?? process.cwd());
201
+ let current = nodePath().resolve(startDir ?? process.cwd());
200
202
 
201
203
  // eslint-disable-next-line no-constant-condition
202
204
  while (true) {
203
- if (await hasWorkspacesField(current)) {
205
+ if (hasWorkspacesField(current)) {
204
206
  return Result.ok(current);
205
207
  }
206
208
 
207
- const parent = dirname(current);
209
+ const parent = nodePath().dirname(current);
208
210
 
209
211
  if (parent === current) {
210
212
  return Result.err(
@@ -225,6 +227,7 @@ export const isInsideWorkspace = (
225
227
  filePath: string,
226
228
  workspaceRoot: string
227
229
  ): boolean => {
230
+ const { isAbsolute, relative, resolve } = nodePath();
228
231
  const rel = relative(resolve(workspaceRoot), resolve(filePath));
229
232
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
230
233
  };
@@ -235,4 +238,7 @@ export const isInsideWorkspace = (
235
238
  export const deriveRelativePath = (
236
239
  filePath: string,
237
240
  workspaceRoot: string
238
- ): string => relative(resolve(workspaceRoot), resolve(filePath));
241
+ ): string => {
242
+ const { relative, resolve } = nodePath();
243
+ return relative(resolve(workspaceRoot), resolve(filePath));
244
+ };