@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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @ontrails/core
2
2
 
3
+ ## 1.0.0-beta.39
4
+
5
+ ### Patch Changes
6
+
7
+ - [`f42ca6e`](https://github.com/outfitter-dev/trails/commit/f42ca6e40b29155acec446e5bf44e52e014466bd): Hard cutover: the CLI consumes `cli` bindings from the app-authored surfaces overlay. Scalar bindings behave identically to the removed cliAliases (parity-tested) — the binding name splits on `.` into a transparent synonym command path for exactly one trail. List bindings arrive as command groups: each expanded member trail gets a group-prefixed route that dispatches the member trail with its identity preserved, and a singleton list stays a group. Expansion is fail-fast boundary validation: a scalar binding resolving to zero or multiple trails, or a group with an empty member union, is a `ValidationError` naming the binding. `DeriveTopoGraphOptions.cliAliases`, the `cliAliases`/`trailsCliAliases` app-module export convention, and the per-kind compile lift are deleted; `deriveCliCommands`/`createProgram` take `overlays` instead of `aliases`, and both topo-graph derivation pipelines expand the same bindings through one shared helper so runtime CLI routes and lock routes come from one semantic. A leftover legacy export is now a Warden error (`no-legacy-cli-alias-export`) naming the `surfaceOverlay({ cli: { ... } })` rewrite.
8
+
9
+ This is a breaking API removal shipped under the lockstep beta patch convention (pre-1.0 hard-cutover posture, zero external adoption); the removed options have no deprecation window by design.
10
+
11
+ - [`6b75a46`](https://github.com/outfitter-dev/trails/commit/6b75a46ab6210237d306cceade833bf9ce6e7431): The core barrel is now execution-portable: no eager `bun:`/`node:` builtin imports remain on its module graph (TRL-1198). `trails-db`, workspace discovery, and path security load `bun:sqlite`, `node:fs`, `node:os`, and `node:path` lazily through `process.getBuiltinModule` at first use, and signal payload summaries plus per-project store keys use a pure SHA-256 (output-identical to `node:crypto`). A Worker bundle no longer needs a `bun:sqlite` stub plugin or the `nodejs_compat` flag to serve trails; the Cloudflare adapter's miniflare lane now bundles without externals and boots workerd without `nodejs_compat` as the structural regression gate, and its README stub instructions are replaced with the portable posture. Tooling helpers throw a clear `InternalError` naming the missing builtin when called on runtimes without it.
12
+ - [`81373bc`](https://github.com/outfitter-dev/trails/commit/81373bc5e980bb06d56fb06af4f0986f72e318c7): Wave-2 MCP cutover to the app-authored `surfaces` overlay. The overlay's `mcp` bindings are now the authored, lockable default for the MCP surface: a list binding derives one grouped trailhead tool (member selection in `{ trail, input }`, member identity preserved in `{ trail, output }`, deterministic derived description), and a scalar binding derives an additional tool synonym whose MCP-safe name is published verbatim and must expand to exactly one trail. `deriveMcpTools`/`createServer` accept the new `overlays` option; `@ontrails/core` gains `expandMcpSurfaceBindings` and `deriveMcpTrailheadDescription`.
13
+
14
+ The call-site `CreateServerOptions.trailheads` map survives as permanent override-in-context design, not a compatibility bridge: when both channels are present, the call-site map wins at runtime. Warden's new `trailhead-override-divergence` rule (warn) names both sides when a call-site map's binding names or member selectors diverge from the authored overlay default.
15
+
16
+ Topographer now derives `graph.trailheads` from the overlay's `mcp` list bindings in both `deriveTopoGraph` and the store-side graph build, so trailhead facts flow from compiled locks into Wayfinder reads for the first time. The never-wired `DeriveTopoGraphOptions.trailheads` option and the `TopoGraphTrailheadDeclaration`/`TopoGraphTrailheadTrailSelector` types are removed — a beta-window hard cutover of an option no caller could reach; author the equivalent `mcp` list binding in `surfaceOverlay({ mcp })` instead.
17
+
18
+ - [`820b4ad`](https://github.com/outfitter-dev/trails/commit/820b4ad9c40ea383b3c489a05fe7e4b2328e324f): Add `surfaceOverlay` — the shared surface-naming schema (scalar binding = synonym, list binding = grouped entry, singleton list stays a group) with app-authored/adapter-derived overlay provenance enforced at collection and consumption, and the `surface-overlay-coherence` Warden rule. MCP tool-name derivation moves to `@ontrails/core` (`deriveMcpToolName`) so the surface and governance read one projection; `@ontrails/mcp`'s `deriveToolName` now delegates to it. The coherence rule activates on standard warden runs once fresh derivations collect app-module overlays through the shared compile channel (TRL-1209, next in this stack).
19
+ - [`28d75fb`](https://github.com/outfitter-dev/trails/commit/28d75fbadecc62794f43957bc3aca11a4cf39c51): Add `forkVersion()` so fork version entries get typed blazes (TRL-1180). `TrailVersions` fixes every entry's generics to `unknown`, which left fork blazes with `unknown` input and forced authors to re-parse the already-validated value just to narrow it. `forkVersion({ input, output, blaze, ... })` threads the entry's own schemas into the blaze signature (including merged `composeInput` fields) and enforces the entry's output shape at compile time; the erasure back to `TrailVersionEntry` is sound because the fork pipeline validates raw input against the entry's own schema before dispatch. `TrailVersionForkSpec` is exported alongside it.
20
+ - [`b9e82a3`](https://github.com/outfitter-dev/trails/commit/b9e82a33546356c93fbc302fb934a83f19f1c2c5): Webhook ingress v2 (TRL-1194, absorbing TRL-1174 and TRL-1175): store-verified, per-endpoint webhook ingress becomes framework-expressible. `webhook()` accepts dynamic path segments (`path: '/hooks/:endpoint'`) whose values are delivered as envelope fields, opt-in `rawBody: true` delivery (a non-JSON body is no longer a surface-level failure — the trail owns payload interpretation), an allowlisted `headers` list delivered lowercased, and `resources` that make `verify` resource-capable: the HTTP surface resolves the declared resources into a context for the verifier and releases them afterwards, so signature checks can reach stores holding per-endpoint secrets. Envelope-mode ingress responds 202 Accepted; classic static webhooks keep their exact-match, JSON-gated, 200 behavior. Core exports `parseWebhookPathParams`, `matchWebhookPath`, `webhookPathPatternsOverlap`, and `createResources`. The `webhook-route-collision` Warden rule now also flags dynamic patterns that overlap other webhook or derived routes, not just exact method/path duplicates.
21
+
22
+ ## 1.0.0-beta.38
23
+
24
+ ### Patch Changes
25
+
26
+ - [`a105127`](https://github.com/outfitter-dev/trails/commit/a105127e5662ed9a6c245125f791fb0182da3f5e): Add the `@ontrails/cloudflare` adapter collection with its first two service subpaths. `@ontrails/cloudflare/workers` exports `createWorkersHandler`, a materializer producing the `{ fetch(request, env, ctx) }` Worker export on the shared HTTP fetch kernel, with an env bridge that re-resolves env-bound resources whenever a new Worker `env` arrives so no resource instance serves a request with a stale env. `@ontrails/cloudflare/kv` exports `cloudflareKv`, a resource definition wrapping a KV namespace binding (`get`/`put`/`delete`/`list` with TTL options) plus an in-memory `createMemoryKv` mock so `testAll` runs configuration-free.
27
+
28
+ `@ontrails/core` now guards the default trail context fields: `requestId` falls back to `crypto.randomUUID()` when the `Bun` global is absent, and `cwd`/`env` fall back to `'/'`/`{}` when `process` is absent, so trail execution works on runtimes like Cloudflare Workers.
29
+
30
+ `@ontrails/warden` registers the `@ontrails/cloudflare` public barrel in the repo-local `public-export-example-coverage` policy, requiring `@example` TSDoc coverage on `createWorkersHandler` and `cloudflareKv`.
31
+
32
+ ## 1.0.0-beta.37
33
+
34
+ ## 1.0.0-beta.36
35
+
36
+ ## 1.0.0-beta.35
37
+
38
+ ### Patch Changes
39
+
40
+ - [`417bd84`](https://github.com/outfitter-dev/trails/commit/417bd8471d0f0f47ad5f33cd2ac1c606eccd72f8): Promote signal trace helpers from tracing compatibility code to core exports, and make tracing's memory sink wrapper use the observe-owned implementation.
41
+ - [`a88114b`](https://github.com/outfitter-dev/trails/commit/a88114b4dd0772db6b58ecdb7671e4169e6bdca5): Expose stable typed topo diagnostics for missing references so downstream
42
+ migration tooling can consume validation results without parsing human messages.
43
+
44
+ ## 1.0.0-beta.34
45
+
46
+ ## 1.0.0-beta.33
47
+
3
48
  ## 1.0.0-beta.32
4
49
 
5
50
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/core",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.39",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
package/src/context.ts CHANGED
@@ -21,11 +21,25 @@ export const passthroughTrace: TraceFn = async <T>(
21
21
  fn: () => T | Promise<T>
22
22
  ): Promise<T> => await fn();
23
23
 
24
+ const defaultCwd = (): string =>
25
+ typeof process === 'undefined' ? '/' : process.cwd();
26
+
27
+ const defaultEnv = (): Record<string, string | undefined> =>
28
+ typeof process === 'undefined'
29
+ ? {}
30
+ : (process.env as Record<string, string | undefined>);
31
+
32
+ const defaultRequestId = (): string =>
33
+ typeof Bun === 'undefined' ? crypto.randomUUID() : Bun.randomUUIDv7();
34
+
24
35
  /**
25
36
  * Create a TrailContext with sensible defaults.
26
37
  *
27
- * - `requestId` defaults to `Bun.randomUUIDv7()` (sortable v7 UUID)
38
+ * - `requestId` defaults to `Bun.randomUUIDv7()` (sortable v7 UUID), falling
39
+ * back to `crypto.randomUUID()` on runtimes without the `Bun` global
28
40
  * - `abortSignal` defaults to a fresh, non-aborted `AbortSignal`
41
+ * - `cwd`/`env` default to the `process` globals when available, and to
42
+ * `'/'`/`{}` on runtimes without `process` (for example Cloudflare Workers)
29
43
  * - All other fields come from `overrides`
30
44
  */
31
45
  export const createTrailContext = (
@@ -33,10 +47,10 @@ export const createTrailContext = (
33
47
  ): TrailContext => {
34
48
  const ctx = {
35
49
  abortSignal: new AbortController().signal,
36
- cwd: process.cwd(),
50
+ cwd: defaultCwd(),
37
51
  dryRun: false,
38
- env: process.env as Record<string, string | undefined>,
39
- requestId: Bun.randomUUIDv7(),
52
+ env: defaultEnv(),
53
+ requestId: defaultRequestId(),
40
54
  trace: passthroughTrace,
41
55
  ...overrides,
42
56
  } as MutableTrailContext;
package/src/derive.ts CHANGED
@@ -382,6 +382,27 @@ const normalizeCliAlias = ({
382
382
  };
383
383
  };
384
384
 
385
+ /**
386
+ * Convert app name + trail ID to an MCP-safe tool name.
387
+ *
388
+ * MCP tool names must be `[a-z0-9_]+`: the app name prefixes the trail id,
389
+ * dots and hyphens collapse to underscores, and everything lowercases. This
390
+ * is the one owner for the projection — the MCP surface renders tools with
391
+ * it and Warden checks binding-name collisions against it, so the two
392
+ * readers cannot drift.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * deriveMcpToolName('myapp', 'entity.show'); // "myapp_entity_show"
397
+ * deriveMcpToolName('dispatch', 'patch.search'); // "dispatch_patch_search"
398
+ * ```
399
+ */
400
+ export const deriveMcpToolName = (appName: string, trailId: string): string => {
401
+ const prefix = appName.toLowerCase().replaceAll(/[.-]/g, '_');
402
+ const suffix = trailId.toLowerCase().replaceAll(/[.-]/g, '_');
403
+ return `${prefix}_${suffix}`;
404
+ };
405
+
385
406
  /** Derive resolved CLI command routes for one trail. */
386
407
  export const deriveTrailCliCommandProjection = (
387
408
  trail: TrailCliProjectionInput,
package/src/index.ts CHANGED
@@ -143,8 +143,13 @@ export {
143
143
  isResource,
144
144
  resource,
145
145
  } from './resource.js';
146
- export { drainResources, resolveResourceConfig } from './resource-config.js';
146
+ export {
147
+ createResources,
148
+ drainResources,
149
+ resolveResourceConfig,
150
+ } from './resource-config.js';
147
151
  export type {
152
+ ResolvedResourceScope,
148
153
  ResourceConfigValues,
149
154
  ResourceDrainReport,
150
155
  } from './resource-config.js';
@@ -186,6 +191,7 @@ export {
186
191
  export type { ActivationSourceProjection } from './activation-source-projection.js';
187
192
  export {
188
193
  deriveSupportedTrailVersions,
194
+ forkVersion,
189
195
  getTrailVersionEntryKind,
190
196
  hasDeprecatedTrailVersionGuidance,
191
197
  intentValues,
@@ -224,6 +230,7 @@ export type {
224
230
  TrailVersionArchivedStatus,
225
231
  TrailVersionDeprecatedStatus,
226
232
  TrailVersionForkEntry,
233
+ TrailVersionForkSpec,
227
234
  TrailVersionRevisionEntry,
228
235
  TrailVersions,
229
236
  TrailVersionStatus,
@@ -302,10 +309,13 @@ export type {
302
309
  export {
303
310
  getWebhookHeader,
304
311
  getWebhookHeaders,
312
+ matchWebhookPath,
313
+ parseWebhookPathParams,
305
314
  validateWebhookSource,
306
315
  verifyWebhookRequest,
307
316
  webhook,
308
317
  webhookMethods,
318
+ webhookPathPatternsOverlap,
309
319
  } from './webhook.js';
310
320
  export type {
311
321
  WebhookMethod,
@@ -410,6 +420,31 @@ export type {
410
420
  export { topo } from './topo.js';
411
421
  export type { Topo, TopoIdentity } from './topo.js';
412
422
 
423
+ // Surface overlay
424
+ export {
425
+ SURFACES_OVERLAY_NAMESPACE,
426
+ classifySurfaceBinding,
427
+ deriveMcpTrailheadDescription,
428
+ expandCliSurfaceBindings,
429
+ expandMcpSurfaceBindings,
430
+ resolveSurfaceOverlayBindings,
431
+ surfaceBindingsFromLockOverlays,
432
+ surfaceOverlay,
433
+ surfaceOverlayBindingsSchema,
434
+ } from './surface-overlay.js';
435
+ export type {
436
+ CliSurfaceBindingAliases,
437
+ McpSurfaceBindingExpansion,
438
+ OverlayEnvelopeLike,
439
+ OverlayProvenance,
440
+ SurfaceBindingRef,
441
+ SurfaceBindingShape,
442
+ SurfaceBindingValue,
443
+ SurfaceBindings,
444
+ SurfaceOverlay,
445
+ SurfaceOverlayBindings,
446
+ } from './surface-overlay.js';
447
+
413
448
  // Generic trails-db helpers (shared framework infrastructure per ADR-0014).
414
449
  // The topo-store public API that previously lived here moved to
415
450
  // `@ontrails/topographer` per ADR-0042.
@@ -447,8 +482,15 @@ export type {
447
482
  } from './draft.js';
448
483
 
449
484
  // Topo validation
450
- export { validateTopo } from './validate-topo.js';
451
- export type { TopoDiagnostic, TopoIssue } from './validate-topo.js';
485
+ export { getTopoDiagnostics, validateTopo } from './validate-topo.js';
486
+ export type {
487
+ TopoDiagnostic,
488
+ TopoDiagnosticCode,
489
+ TopoIssue,
490
+ TopoMissingReference,
491
+ TopoReferenceKind,
492
+ TopoReferenceOwnerKind,
493
+ } from './validate-topo.js';
452
494
  export { validateEstablishedTopo } from './validate-established-topo.js';
453
495
 
454
496
  // Layer
@@ -473,6 +515,7 @@ export type {
473
515
  export {
474
516
  deriveCliPath,
475
517
  deriveFields,
518
+ deriveMcpToolName,
476
519
  deriveTrailCliCommandProjection,
477
520
  normalizeCliCommandPath,
478
521
  } from './derive.js';
@@ -506,6 +549,7 @@ export type { ExecuteTrailOptions } from './execute.js';
506
549
  export {
507
550
  clearTraceSink,
508
551
  createActivationTraceRecord,
552
+ createSignalTraceRecord,
509
553
  createTraceRecord,
510
554
  getTraceContext,
511
555
  getTraceSink,
@@ -514,6 +558,7 @@ export {
514
558
  TRACE_CONTEXT_KEY,
515
559
  traceContextFromRecord,
516
560
  writeActivationTraceRecord,
561
+ writeSignalTraceRecord,
517
562
  } from './tracing.js';
518
563
  export type {
519
564
  ActivationTraceRecordName,
@@ -4,10 +4,14 @@
4
4
  * All functions are runtime-agnostic (Node / Bun compatible).
5
5
  */
6
6
 
7
- import { resolve, relative, normalize, isAbsolute } from 'node:path';
8
-
9
7
  import { PermissionError } from './errors.js';
10
8
  import { Result } from './result.js';
9
+ // Path security guards filesystem access on tooling paths: node:path
10
+ // loads lazily at first use so the core barrel's module graph stays
11
+ // execution-portable on runtimes without node: builtins (TRL-1198).
12
+ import { loadRuntimeBuiltin } from './runtime-builtins.js';
13
+
14
+ const nodePath = () => loadRuntimeBuiltin('node:path');
11
15
 
12
16
  // ---------------------------------------------------------------------------
13
17
  // Internal
@@ -15,6 +19,7 @@ import { Result } from './result.js';
15
19
 
16
20
  /** Returns true when `target` is equal to or a descendant of `base`. */
17
21
  const isWithin = (base: string, target: string): boolean => {
22
+ const { isAbsolute, relative } = nodePath();
18
23
  const rel = relative(base, target);
19
24
  // Empty string means they are the same directory.
20
25
  // A relative path starting with ".." means it escapes.
@@ -39,6 +44,7 @@ export const securePath = (
39
44
  basePath: string,
40
45
  userPath: string
41
46
  ): Result<string, PermissionError> => {
47
+ const { resolve } = nodePath();
42
48
  const base = resolve(basePath);
43
49
  const resolved = resolve(base, userPath);
44
50
 
@@ -61,6 +67,7 @@ export const securePath = (
61
67
  * `basePath`.
62
68
  */
63
69
  export const isPathSafe = (basePath: string, userPath: string): boolean => {
70
+ const { resolve } = nodePath();
64
71
  const base = resolve(basePath);
65
72
  const resolved = resolve(base, userPath);
66
73
  return isWithin(base, resolved);
@@ -74,6 +81,7 @@ export const deriveSafePath = (
74
81
  basePath: string,
75
82
  ...segments: string[]
76
83
  ): Result<string, PermissionError> => {
84
+ const { normalize, resolve } = nodePath();
77
85
  const base = resolve(basePath);
78
86
  const joined = resolve(base, ...segments.map((s) => normalize(s)));
79
87
 
@@ -585,7 +585,7 @@ const withResolvedResources = (
585
585
  /**
586
586
  * Resolved trail context plus lease release for resources used by the run.
587
587
  */
588
- interface ResolvedResourceScope {
588
+ export interface ResolvedResourceScope {
589
589
  readonly ctx: TrailContext;
590
590
  release(): void;
591
591
  }
@@ -593,14 +593,26 @@ interface ResolvedResourceScope {
593
593
  const releaseNoResources = (): undefined => undefined;
594
594
 
595
595
  /**
596
- * Resolve all declared resources for a trail.
596
+ * Resolve all declared resources for a resource-bearing declaration.
597
597
  *
598
598
  * Validates per-resource config, checks overrides and caches, and creates
599
599
  * new instances as needed. Returns an enriched context with all resource
600
- * instances injected into extensions.
600
+ * instances injected into extensions. Callers own the returned `release`.
601
+ *
602
+ * @example
603
+ * ```ts
604
+ * const scope = await createResources(
605
+ * { resources: [secretsStore] },
606
+ * createTrailContext()
607
+ * );
608
+ * if (scope.isOk()) {
609
+ * const secrets = secretsStore.from(scope.value.ctx);
610
+ * scope.value.release();
611
+ * }
612
+ * ```
601
613
  */
602
614
  export const createResources = async (
603
- trail: AnyTrail,
615
+ trail: Pick<AnyTrail, 'resources'>,
604
616
  ctx: TrailContext,
605
617
  overrides?: ResourceOverrideMap,
606
618
  configValues?: ConfigValues
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Lazy runtime-builtin loading for the execution-portable core barrel.
3
+ *
4
+ * Core tooling modules that need Bun or Node capabilities (`bun:sqlite`,
5
+ * `node:fs`, `node:os`, `node:path`) must not import them eagerly: the
6
+ * core barrel sits on the execution path of every surface, and edge
7
+ * runtimes such as workerd refuse module graphs that import `bun:`
8
+ * builtins and gate `node:` builtins behind compatibility flags
9
+ * (TRL-1198). Loading through `process.getBuiltinModule` keeps the
10
+ * specifier out of bundler module graphs entirely and defers the
11
+ * capability requirement to first use, so runtimes that never call a
12
+ * tooling helper never pay for it.
13
+ */
14
+
15
+ import type * as BunSqlite from 'bun:sqlite';
16
+ import type * as NodeFs from 'node:fs';
17
+ import type * as NodeOs from 'node:os';
18
+ import type * as NodePath from 'node:path';
19
+
20
+ import { InternalError } from './errors.js';
21
+
22
+ interface BuiltinModules {
23
+ readonly 'bun:sqlite': typeof BunSqlite;
24
+ readonly 'node:fs': typeof NodeFs;
25
+ readonly 'node:os': typeof NodeOs;
26
+ readonly 'node:path': typeof NodePath;
27
+ }
28
+
29
+ const loadedBuiltins = new Map<keyof BuiltinModules, unknown>();
30
+
31
+ /**
32
+ * Load a Bun/Node builtin module at first use.
33
+ *
34
+ * @throws {InternalError} When the runtime does not expose the builtin
35
+ * (for example workerd without `nodejs_compat`). Trail execution never
36
+ * reaches this loader; only tooling helpers (trails-db, workspace
37
+ * discovery) do.
38
+ */
39
+ export const loadRuntimeBuiltin = <TName extends keyof BuiltinModules>(
40
+ name: TName
41
+ ): BuiltinModules[TName] => {
42
+ const cached = loadedBuiltins.get(name);
43
+ if (cached !== undefined) {
44
+ return cached as BuiltinModules[TName];
45
+ }
46
+
47
+ const proc = (
48
+ globalThis as {
49
+ readonly process?: {
50
+ readonly getBuiltinModule?: (id: string) => unknown;
51
+ };
52
+ }
53
+ ).process;
54
+ if (typeof proc?.getBuiltinModule !== 'function') {
55
+ throw new InternalError(
56
+ `Runtime builtin "${name}" is unavailable: this runtime does not expose process.getBuiltinModule. Trails tooling helpers need a Bun or Node runtime; the trail execution path never loads them.`
57
+ );
58
+ }
59
+
60
+ const loaded = proc.getBuiltinModule(name);
61
+ if (loaded === undefined || loaded === null) {
62
+ throw new InternalError(
63
+ `Runtime builtin "${name}" is unavailable on this runtime.`
64
+ );
65
+ }
66
+
67
+ loadedBuiltins.set(name, loaded);
68
+ return loaded as BuiltinModules[TName];
69
+ };
package/src/sha256.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Pure synchronous SHA-256 for runtime-portable fingerprints.
3
+ *
4
+ * `node:crypto`'s `createHash` is unavailable on edge runtimes without
5
+ * compatibility flags, and Web Crypto's `subtle.digest` is asynchronous,
6
+ * which does not fit the synchronous summary paths that need a digest
7
+ * (signal payload summaries, per-project store keys). This implementation
8
+ * follows FIPS 180-4 and produces output identical to
9
+ * `createHash('sha256').update(text).digest('hex')`.
10
+ */
11
+
12
+ /* oxlint-disable no-bitwise, unicorn/prefer-math-trunc, unicorn/number-literal-case -- SHA-256 is defined over 32-bit words: rotations, xor, and `>>> 0` unsigned wrapping are the specified operations, and `Math.trunc()` is not equivalent to `>>> 0` (it neither wraps to 32 bits nor coerces to unsigned). number-literal-case is disabled because oxfmt normalizes hex digits to lowercase, which the rule rejects — the formatter wins. */
13
+
14
+ // FIPS 180-4 §4.2.2: first 32 bits of the fractional parts of the cube
15
+ // roots of the first 64 primes.
16
+ const K = Uint32Array.from([
17
+ 0x42_8a_2f_98, 0x71_37_44_91, 0xb5_c0_fb_cf, 0xe9_b5_db_a5, 0x39_56_c2_5b,
18
+ 0x59_f1_11_f1, 0x92_3f_82_a4, 0xab_1c_5e_d5, 0xd8_07_aa_98, 0x12_83_5b_01,
19
+ 0x24_31_85_be, 0x55_0c_7d_c3, 0x72_be_5d_74, 0x80_de_b1_fe, 0x9b_dc_06_a7,
20
+ 0xc1_9b_f1_74, 0xe4_9b_69_c1, 0xef_be_47_86, 0x0f_c1_9d_c6, 0x24_0c_a1_cc,
21
+ 0x2d_e9_2c_6f, 0x4a_74_84_aa, 0x5c_b0_a9_dc, 0x76_f9_88_da, 0x98_3e_51_52,
22
+ 0xa8_31_c6_6d, 0xb0_03_27_c8, 0xbf_59_7f_c7, 0xc6_e0_0b_f3, 0xd5_a7_91_47,
23
+ 0x06_ca_63_51, 0x14_29_29_67, 0x27_b7_0a_85, 0x2e_1b_21_38, 0x4d_2c_6d_fc,
24
+ 0x53_38_0d_13, 0x65_0a_73_54, 0x76_6a_0a_bb, 0x81_c2_c9_2e, 0x92_72_2c_85,
25
+ 0xa2_bf_e8_a1, 0xa8_1a_66_4b, 0xc2_4b_8b_70, 0xc7_6c_51_a3, 0xd1_92_e8_19,
26
+ 0xd6_99_06_24, 0xf4_0e_35_85, 0x10_6a_a0_70, 0x19_a4_c1_16, 0x1e_37_6c_08,
27
+ 0x27_48_77_4c, 0x34_b0_bc_b5, 0x39_1c_0c_b3, 0x4e_d8_aa_4a, 0x5b_9c_ca_4f,
28
+ 0x68_2e_6f_f3, 0x74_8f_82_ee, 0x78_a5_63_6f, 0x84_c8_78_14, 0x8c_c7_02_08,
29
+ 0x90_be_ff_fa, 0xa4_50_6c_eb, 0xbe_f9_a3_f7, 0xc6_71_78_f2,
30
+ ]);
31
+
32
+ const rotr = (value: number, bits: number): number =>
33
+ (value >>> bits) | (value << (32 - bits));
34
+
35
+ /**
36
+ * Total array accessor: every read in the compression loop is provably in
37
+ * range, so the fallback never fires — it only satisfies
38
+ * `noUncheckedIndexedAccess` without an assertion.
39
+ */
40
+ const wordAt = (words: Uint32Array, index: number): number => words[index] ?? 0;
41
+
42
+ const padMessage = (bytes: Uint8Array): Uint8Array => {
43
+ const bitLength = bytes.length * 8;
44
+ // Message + 0x80 marker, padded to 56 mod 64, then a 64-bit big-endian
45
+ // bit length. Message sizes here are far below 2^32 bits, so the high
46
+ // word of the length is derived from the float division.
47
+ const paddedLength = (Math.floor((bytes.length + 8) / 64) + 1) * 64;
48
+ const padded = new Uint8Array(paddedLength);
49
+ padded.set(bytes);
50
+ padded[bytes.length] = 0x80;
51
+ const view = new DataView(padded.buffer);
52
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_00_00_00_00));
53
+ view.setUint32(paddedLength - 4, bitLength >>> 0);
54
+ return padded;
55
+ };
56
+
57
+ const fillSchedule = (w: Uint32Array, view: DataView, offset: number): void => {
58
+ for (let i = 0; i < 16; i += 1) {
59
+ w[i] = view.getUint32(offset + i * 4);
60
+ }
61
+ for (let i = 16; i < 64; i += 1) {
62
+ const w15 = wordAt(w, i - 15);
63
+ const w2 = wordAt(w, i - 2);
64
+ const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3);
65
+ const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10);
66
+ w[i] = (wordAt(w, i - 16) + s0 + wordAt(w, i - 7) + s1) >>> 0;
67
+ }
68
+ };
69
+
70
+ // oxlint-disable-next-line max-statements -- the FIPS 180-4 compression loop reads more clearly as one block than split across helpers
71
+ const digestHex = (padded: Uint8Array): string => {
72
+ // FIPS 180-4 §5.3.3 initial hash value.
73
+ let h0 = 0x6a_09_e6_67;
74
+ let h1 = 0xbb_67_ae_85;
75
+ let h2 = 0x3c_6e_f3_72;
76
+ let h3 = 0xa5_4f_f5_3a;
77
+ let h4 = 0x51_0e_52_7f;
78
+ let h5 = 0x9b_05_68_8c;
79
+ let h6 = 0x1f_83_d9_ab;
80
+ let h7 = 0x5b_e0_cd_19;
81
+
82
+ const view = new DataView(padded.buffer);
83
+ const w = new Uint32Array(64);
84
+
85
+ for (let offset = 0; offset < padded.length; offset += 64) {
86
+ fillSchedule(w, view, offset);
87
+
88
+ let a = h0;
89
+ let b = h1;
90
+ let c = h2;
91
+ let d = h3;
92
+ let e = h4;
93
+ let f = h5;
94
+ let g = h6;
95
+ let h = h7;
96
+
97
+ for (let i = 0; i < 64; i += 1) {
98
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
99
+ const ch = (e & f) ^ (~e & g);
100
+ const temp1 = (h + s1 + ch + wordAt(K, i) + wordAt(w, i)) >>> 0;
101
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
102
+ const maj = (a & b) ^ (a & c) ^ (b & c);
103
+ const temp2 = (s0 + maj) >>> 0;
104
+
105
+ h = g;
106
+ g = f;
107
+ f = e;
108
+ e = (d + temp1) >>> 0;
109
+ d = c;
110
+ c = b;
111
+ b = a;
112
+ a = (temp1 + temp2) >>> 0;
113
+ }
114
+
115
+ h0 = (h0 + a) >>> 0;
116
+ h1 = (h1 + b) >>> 0;
117
+ h2 = (h2 + c) >>> 0;
118
+ h3 = (h3 + d) >>> 0;
119
+ h4 = (h4 + e) >>> 0;
120
+ h5 = (h5 + f) >>> 0;
121
+ h6 = (h6 + g) >>> 0;
122
+ h7 = (h7 + h) >>> 0;
123
+ }
124
+
125
+ return [h0, h1, h2, h3, h4, h5, h6, h7]
126
+ .map((word) => word.toString(16).padStart(8, '0'))
127
+ .join('');
128
+ };
129
+
130
+ /**
131
+ * SHA-256 of the UTF-8 encoding of `text`, as lowercase hex.
132
+ *
133
+ * Output-identical to `createHash('sha256').update(text).digest('hex')`.
134
+ */
135
+ export const sha256Hex = (text: string): string =>
136
+ digestHex(padMessage(new TextEncoder().encode(text)));
@@ -5,11 +5,10 @@
5
5
  * observable without changing the best-effort producer API.
6
6
  */
7
7
 
8
- import { createHash } from 'node:crypto';
9
-
10
8
  import type { z } from 'zod';
11
9
 
12
10
  import type { ActivationProvenance } from './activation-provenance.js';
11
+ import { sha256Hex } from './sha256.js';
13
12
  import type { Logger } from './types.js';
14
13
 
15
14
  export const SIGNAL_DIAGNOSTICS_SINK_KEY =
@@ -349,8 +348,9 @@ const stableJson = (value: unknown): string => {
349
348
  }
350
349
  };
351
350
 
352
- const hashText = (text: string): string =>
353
- createHash('sha256').update(text).digest('hex');
351
+ // Pure sha256 keeps payload summarization on the execution path portable:
352
+ // node:crypto is unavailable on edge runtimes without compat flags.
353
+ const hashText = (text: string): string => sha256Hex(text);
354
354
 
355
355
  export const summarizeSignalPayload = (
356
356
  payload: unknown