@ontrails/core 1.0.0-beta.30 → 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,26 +5,189 @@
5
5
  * helpers for working with paths relative to a workspace.
6
6
  */
7
7
 
8
- import { resolve, relative, dirname, join, isAbsolute } from 'node:path';
9
-
10
8
  import { NotFoundError } from './errors.js';
11
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';
12
14
 
13
- /** Check if a directory has a package.json with a `workspaces` field. */
14
- const hasWorkspacesField = async (dir: string): Promise<boolean> => {
15
- const pkgPath = join(dir, 'package.json');
16
- const file = Bun.file(pkgPath);
17
- if (!(await file.exists())) {
18
- return false;
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));
19
41
  }
42
+ };
43
+
44
+ const readJsonSync = <T>(path: string): T | undefined => {
20
45
  try {
21
- const pkg: unknown = await file.json();
22
- return typeof pkg === 'object' && pkg !== null && 'workspaces' in pkg;
46
+ return JSON.parse(fs().readFileSync(path, 'utf8')) as T;
23
47
  } catch {
24
- return false;
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
+ });
25
166
  }
167
+
168
+ return packages.toSorted((left, right) =>
169
+ left.workspacePath.localeCompare(right.workspacePath)
170
+ );
26
171
  };
27
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
+
28
191
  /**
29
192
  * Walks up from `startDir` (defaults to `process.cwd()`) looking for a
30
193
  * `package.json` that contains a `"workspaces"` field.
@@ -35,15 +198,15 @@ const hasWorkspacesField = async (dir: string): Promise<boolean> => {
35
198
  export const findWorkspaceRoot = async (
36
199
  startDir?: string
37
200
  ): Promise<Result<string, NotFoundError>> => {
38
- let current = resolve(startDir ?? process.cwd());
201
+ let current = nodePath().resolve(startDir ?? process.cwd());
39
202
 
40
203
  // eslint-disable-next-line no-constant-condition
41
204
  while (true) {
42
- if (await hasWorkspacesField(current)) {
205
+ if (hasWorkspacesField(current)) {
43
206
  return Result.ok(current);
44
207
  }
45
208
 
46
- const parent = dirname(current);
209
+ const parent = nodePath().dirname(current);
47
210
 
48
211
  if (parent === current) {
49
212
  return Result.err(
@@ -64,6 +227,7 @@ export const isInsideWorkspace = (
64
227
  filePath: string,
65
228
  workspaceRoot: string
66
229
  ): boolean => {
230
+ const { isAbsolute, relative, resolve } = nodePath();
67
231
  const rel = relative(resolve(workspaceRoot), resolve(filePath));
68
232
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
69
233
  };
@@ -74,4 +238,7 @@ export const isInsideWorkspace = (
74
238
  export const deriveRelativePath = (
75
239
  filePath: string,
76
240
  workspaceRoot: string
77
- ): string => relative(resolve(workspaceRoot), resolve(filePath));
241
+ ): string => {
242
+ const { relative, resolve } = nodePath();
243
+ return relative(resolve(workspaceRoot), resolve(filePath));
244
+ };