@ultimat3/core 11.3.0 → 13.0.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/CLAUDE.md CHANGED
@@ -121,6 +121,7 @@ shape against a locally declared sample interface for exactly that reason.
121
121
  | how many at once | `flight-gate.ts` | hand-over on release, refusal past `maxQueued`, injectable `overflow:` refusal |
122
122
  | whether an answer still applies | `generation-fence.ts` | `X_SUPERSEDED` / `isSuperseded`; nothing else in the tree had one |
123
123
  | which HTTP statuses are worth repeating | `retryable-status.ts` | `>= 500` plus 408, 409, 425, 429 — the two byte-identical copies' set |
124
+ | how long this request has left | `request-budget.ts` (`Ctx.deadlineAt`, `REQUEST_TIMEOUT_HEADER`) | `@ultimat3/http`'s `startDeadline` is the one production writer of the instant; `traceHeaders()` is the one writer of the header. It lives here because the READER is tier 2 and the WRITER is a typed client in tier 0, and a second literal for the header name is a propagation that stops working the day either string is edited. A spent budget sends nothing rather than `0` — the far side ignores anything under 1ms and falls back to its own, which is the failure the header exists to prevent, one hop later |
124
125
  | the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
125
126
  | is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
126
127
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
@@ -449,6 +450,32 @@ Gotchas:
449
450
  where an app reads an undeclared service, which is the point, but `examples/dummy` ships one
450
451
  such read and it would land on the app gate's ratchet. Land it as its own change, alone, with a
451
452
  full `bun run verify` — never folded into another branch.
453
+ - **`Ctx extends CtxFacts, CtxServices`, and `createContext` holds the framework's ONE irreducible
454
+ assertion** (`As of 2026-08-24`). Different hole from the bullet above, and the note there —
455
+ "an augmentation adds NAMED members and `Ctx extends CtxServices` picks them up with no index
456
+ signature at all" — is exactly why: those NAMED members are then REQUIRED of every value typed
457
+ `Ctx`, and no framework function can obtain them. They arrive through `init.services` (a
458
+ `ServiceBag`, string-indexed) and through `installedServices()`, which returns the same. So
459
+ `createContext` cannot type-check its own literal against `Ctx`, and neither could
460
+ `@ultimat3/http`'s `createRequestContext`, which failed to compile inside `examples/dummy` with
461
+ `TS2739: missing posts, orgs` while this repo's own gate — augmenting nothing — stayed green.
462
+
463
+ `CtxFacts` is everything the FRAMEWORK sets; `Ctx` is that plus `CtxServices`. Structurally
464
+ identical for a reader, and everything for a constructor. It bought two deletions: the `preview`
465
+ assertion is gone (that value is honestly a `CtxFacts`, which is also what a `ServiceFactory`
466
+ receives — a factory has never been able to read a sibling service and the type now says so),
467
+ and `@ultimat3/http` has **no assertion at all**, because `createRequestContext` composes
468
+ `createContext()` instead of building a second context beside it.
469
+
470
+ **One `as Ctx` remains and four alternatives were built and measured before it was kept.**
471
+ `Partial<CtxServices>` removes it and makes `ctx.posts` `PostRepo | undefined` for every app —
472
+ true, and a breaking change to the documented seam. Typing `CtxInit.services` as `CtxServices`
473
+ moves the proof to the caller and breaks every internal `createContext()` in an app's program,
474
+ because an app typechecks this tree's sources through its project references. A generic
475
+ `createContext<S>` returns a context no framework caller can pass where a `Ctx` is wanted. An
476
+ overload whose implementation signature returns the looser type compiles only through
477
+ TypeScript's documented bivariance hole — the same assertion, laundered. The file header carries
478
+ this list; the structural repair is a major and belongs with the index-signature deletion above.
452
479
  - Tests that touch the registry, the lifecycle or the listener table must call
453
480
  `resetErrorCodes()` / `resetLifecycle()` / `resetListeners()`.
454
481
  - `onShutdown`'s return value is the unregister, and every caller that can be started twice owns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "11.3.0",
3
+ "version": "13.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  /**
2
- * What a typed client puts on the wire and reads back off it: the W3C trace header, the
3
- * problem+json body, and the immutable answer one dispatch hands to every caller sharing it.
2
+ * What a typed client puts on the wire and reads back off it: the ambient call context as
3
+ * headers the W3C trace and the remaining request budget the problem+json body, and the
4
+ * immutable answer one dispatch hands to every caller sharing it.
4
5
  *
5
6
  * Tier 0 because `@ultimat3/action` and `@ultimat3/query` need this identical file and are both
6
7
  * tier 3, so neither may import the other — the shape `canonical-json.ts` is already here for.
@@ -11,6 +12,7 @@
11
12
  import type { ErrorRetry } from './error-retry';
12
13
  import { declaredErrorRetry } from './error-retry';
13
14
  import { isJsonObject } from './json-object';
15
+ import { budgetHeaders } from './request-budget';
14
16
  import { isRetryableStatus } from './retryable-status';
15
17
  import { currentSpanContext, traceparent } from './telemetry';
16
18
 
@@ -30,17 +32,31 @@ const TRACE_ID = /^[0-9a-f]{32}$/;
30
32
  const SPAN_ID = /^[0-9a-f]{16}$/;
31
33
 
32
34
  /**
33
- * The current trace, as the W3C header or nothing at all. `currentSpanContext()` answers with
34
- * an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
35
- * a header every collector drops, so an incomplete context sends none. In a browser there is no
36
- * ambient context and this is always empty, which is also what keeps a cross-origin call from
37
- * acquiring a CORS preflight it did not have.
35
+ * Everything this process knows about the call in flight, as headers: the W3C trace, and how much
36
+ * of the request budget is left. Both callers (`@ultimat3/action`'s `postOnce` and
37
+ * `@ultimat3/query`'s reader) spread it BEFORE the caller's own headers, so an explicit value
38
+ * still wins.
39
+ *
40
+ * The trace half: `currentSpanContext()` answers with an empty `spanId` when a request context
41
+ * exists but no span is active, and `00-<trace>--01` is a header every collector drops, so an
42
+ * incomplete context sends none. In a browser there is no ambient context and this is always
43
+ * empty, which is also what keeps a cross-origin call from acquiring a CORS preflight it did not
44
+ * have.
45
+ *
46
+ * The budget half is independent of it, and that is deliberate: a deadline must propagate through
47
+ * a hop that is not being traced. `budgetHeaders()` answers `{}` outside a request and for a
48
+ * context with no deadline, so a browser and a job are unchanged — the header only appears where
49
+ * something really is waiting on a socket.
50
+ *
51
+ * The name is the trace half's alone for one reason: renaming it means editing
52
+ * `packages/{action,query}/src/client.ts`, and both spell it as a value import.
38
53
  */
39
54
  export function traceHeaders(): Record<string, string> {
55
+ const budget = budgetHeaders();
40
56
  const context = currentSpanContext();
41
- if (context === undefined) return {};
42
- if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
43
- return { traceparent: traceparent(context) };
57
+ if (context === undefined) return budget;
58
+ if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return budget;
59
+ return { ...budget, traceparent: traceparent(context) };
44
60
  }
45
61
 
46
62
  /**
package/src/context.ts CHANGED
@@ -1,6 +1,34 @@
1
1
  // Single responsibility: the ambient request context. Authz, tracing, locale, tz and the
2
2
  // service bag reach every layer through AsyncLocalStorage instead of being threaded as
3
3
  // parameters — otherwise every signature in the framework grows a `ctx` argument twice.
4
+ //
5
+ // THERE IS EXACTLY ONE ASSERTION IN THIS FILE AND IT IS IRREDUCIBLE (`As of 2026-08-24`). It is
6
+ // the `as Ctx` in `createContext`, and it is the LAST one: the second — over `preview` — is gone,
7
+ // because `CtxFacts` gives that value an honest type, and `@ultimat3/http`'s
8
+ // `createRequestContext` now composes this function instead of building a second context beside
9
+ // it, so that package has none at all.
10
+ //
11
+ // Why the last one cannot go. `Ctx extends CtxServices`, and `CtxServices` is the seam an app
12
+ // augments (`declare module '@ultimat3/core'`) to declare `ctx.posts`. Those members are
13
+ // therefore REQUIRED of any value typed `Ctx` — and this function cannot obtain them: they arrive
14
+ // through `init.services`, a `ServiceBag` with a string index signature, and through
15
+ // `installedServices()`, which returns the same. No function can return a value of a type whose
16
+ // required members it has no way to hold, and no type operator can separate an augmented member
17
+ // from a core one either — the index signature makes `keyof Ctx` `string`, so every `Omit` over it
18
+ // removes everything.
19
+ //
20
+ // Four alternatives were built and measured before this line was kept. Making the augmented half
21
+ // `Partial<CtxServices>` removes the assertion and turns `ctx.posts` into `PostRepo | undefined`
22
+ // for every app — true, and a breaking change to the documented seam. Requiring `CtxInit.services`
23
+ // to be a `CtxServices` moves the proof to the caller and breaks every internal `createContext()`
24
+ // in an app's program, because an app typechecks the framework's sources through its project
25
+ // references. A generic `createContext<S>` returns a context no framework caller can pass where a
26
+ // `Ctx` is wanted. And an overload whose implementation signature returns the looser type compiles
27
+ // only through TypeScript's documented bivariance hole — the same assertion, laundered.
28
+ //
29
+ // So it stays, bounded to that one expression, with `CtxFacts` beside it carrying everything this
30
+ // package CAN prove. The structural repair is to `Ctx extends CtxServices` itself and it is a
31
+ // major: this comment is the record of why it was not done quietly.
4
32
 
5
33
  import { type Actor, anonymousActor } from './actor';
6
34
  import { asyncContext } from './async-context';
@@ -37,7 +65,21 @@ export interface ServiceBag {
37
65
  readonly [service: string]: unknown;
38
66
  }
39
67
 
40
- export interface Ctx extends CtxServices {
68
+ /**
69
+ * Every member the FRAMEWORK sets on a context — core's `Ctx` with the app's `CtxServices`
70
+ * augmentation removed. It exists because a framework function cannot type-check an object literal
71
+ * against a type carrying members only the app's boot knows about: `Ctx extends CtxServices`, an
72
+ * app augments `CtxServices` with `declare module`, and every service it declares then became a
73
+ * REQUIRED member of every context literal in the framework. `@ultimat3/http`'s
74
+ * `createRequestContext` stopped compiling inside `examples/dummy` for exactly that reason
75
+ * (`TS2739: missing posts, orgs`), while the framework's own gate — which augments nothing —
76
+ * stayed green.
77
+ *
78
+ * A service FACTORY is handed this and not a `Ctx`, which is also more honest than what it had:
79
+ * `installedServices` builds the bag, so a factory has never been able to read a sibling service,
80
+ * and the type now says so.
81
+ */
82
+ export interface CtxFacts {
41
83
  readonly requestId: string;
42
84
  /** W3C trace id — the same value crosses HTTP -> job -> live query. */
43
85
  readonly traceId: string;
@@ -52,10 +94,27 @@ export interface Ctx extends CtxServices {
52
94
  now(): Date;
53
95
  readonly logger: Logger;
54
96
  readonly signal: AbortSignal;
97
+ /**
98
+ * Epoch milliseconds this request's budget runs out at, or `null` when nothing set one.
99
+ *
100
+ * The value BEHIND `signal`, published as a number because a signal can only say "already over"
101
+ * — and every outbound hop needs to say how much is LEFT. Without it a service called at t=29 of
102
+ * a 30s budget started a fresh 30s of its own: real work, holding a pool slot and a vendor
103
+ * connection, for half a minute after the caller's socket was answered `X_TIMEOUT`. `null` for a
104
+ * job, a task and a CLI command, none of which has a caller waiting on a socket.
105
+ */
106
+ readonly deadlineAt: number | null;
55
107
  /** Late-bound services, for anything not worth a type augmentation. */
56
108
  readonly services: ServiceBag;
57
109
  }
58
110
 
111
+ /**
112
+ * The context as it EXISTS once a boot's services ride on it: the framework's half plus the app's
113
+ * augmentation. Structurally identical to what `Ctx` has always been — the split above changes
114
+ * nothing a reader sees, and everything a CONSTRUCTOR is asked to prove.
115
+ */
116
+ export interface Ctx extends CtxFacts, CtxServices {}
117
+
59
118
  export interface CtxInit {
60
119
  readonly requestId?: string | undefined;
61
120
  readonly traceId?: string | undefined;
@@ -67,6 +126,8 @@ export interface CtxInit {
67
126
  readonly clock?: Clock | undefined;
68
127
  readonly logger?: Logger | undefined;
69
128
  readonly signal?: AbortSignal | undefined;
129
+ /** Epoch ms. `@ultimat3/http`'s `startDeadline` is the one production writer. */
130
+ readonly deadlineAt?: number | undefined;
70
131
  readonly services?: ServiceBag | undefined;
71
132
  }
72
133
 
@@ -112,6 +173,7 @@ export function createContext(init: CtxInit = {}): Ctx {
112
173
  now: () => clock.now(),
113
174
  logger: base.child({ requestId, traceId: trace }),
114
175
  signal: init.signal ?? neverAborted,
176
+ deadlineAt: init.deadlineAt ?? null,
115
177
  };
116
178
  // A registered service (`defineService`) closes over the ctx it is built for — actor, clock,
117
179
  // tz — so it has to run HERE, against this exact call's fields, rather than once at boot and
@@ -120,16 +182,17 @@ export function createContext(init: CtxInit = {}): Ctx {
120
182
  // what stops factories from depending on one another's instances. Explicit `init.services`
121
183
  // wins over an auto-installed one of the same name — a test's hand-built mock overrides the
122
184
  // real thing on purpose.
123
- const preview = Object.freeze({ ...explicit, ...fields, services: explicit }) as Ctx;
185
+ const preview: CtxFacts = Object.freeze({ ...explicit, ...fields, services: explicit });
124
186
  const services: ServiceBag = Object.freeze({ ...installedServices(preview), ...explicit });
125
187
  const ctx = {
126
188
  // Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
127
189
  // augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
128
190
  // with a framework field (`actor`, `logger`) loses — it stays reachable as
129
191
  // `ctx.services.actor`, and the context's own meaning never depends on what an app named a
130
- // service. The assertion is the one thing this package cannot prove: an augmentation
131
- // declares which services exist, only the boot code knows whether it passed them, or
132
- // registered a factory for them. So a service nothing installed reads as `undefined`
192
+ // service. The `as Ctx` below is the file's ONE assertion and the header says why it cannot
193
+ // be removed: an augmentation declares which services exist, only the boot code knows whether
194
+ // it passed them or registered a factory for them, and a `ServiceBag` cannot prove either.
195
+ // So a service nothing installed reads as `undefined`
133
196
  // through `ctx.posts` — this is a frozen plain object, and it stays one on purpose: a
134
197
  // get-trap proxy that threw on absent keys would also throw on `await ctx` (the runtime
135
198
  // probes `.then`), on `JSON.stringify`, and on every optional-property check.
@@ -191,6 +254,10 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
191
254
  role: patch.role ?? parent.role,
192
255
  clock: patch.clock ?? parent.clock,
193
256
  logger: patch.logger ?? parent.logger,
257
+ // One request, one budget: a child scope inherits the deadline for the same reason it
258
+ // inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
259
+ // lengthens it, because the socket the parent is answering does not move.
260
+ deadlineAt: patch.deadlineAt ?? parent.deadlineAt ?? undefined,
194
261
  signal: patch.signal ?? parent.signal,
195
262
  services: { ...carried, ...(patch.services ?? {}) },
196
263
  });
package/src/index.ts CHANGED
@@ -72,7 +72,7 @@ export type {
72
72
  ThemeMode,
73
73
  } from './config';
74
74
  export { defineConfig } from './config';
75
- export type { Ctx, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
75
+ export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
76
76
  export {
77
77
  createContext,
78
78
  DEFAULT_LOCALE,
@@ -509,6 +509,11 @@ export {
509
509
  registerPrimitiveRegistrar,
510
510
  resetPrimitiveRegistrars,
511
511
  } from './registrar';
512
+ export {
513
+ budgetHeaders,
514
+ REQUEST_TIMEOUT_HEADER,
515
+ remainingBudgetMs,
516
+ } from './request-budget';
512
517
  export type { Err, Ok, Result } from './result';
513
518
  export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from './result';
514
519
  export type { RetryDecision, RetryDeps, RetryPolicy, RetryStopReason } from './retry';
@@ -530,3 +535,24 @@ export {
530
535
  VERSION_DEFINE,
531
536
  VERSION_MANIFEST,
532
537
  } from './version';
538
+ // The webhook wire format, at the tier both halves can reach — `@ultimat3/jobs` signs a delivery
539
+ // and `@ultimat3/http` verifies one, and neither may import the other. Same argument
540
+ // `timing-safe-equal.ts` makes for itself, one line above.
541
+ export type {
542
+ WebhookMacInput,
543
+ WebhookSignatureFields,
544
+ WebhookSigningInput,
545
+ } from './webhook-signature';
546
+ export {
547
+ isCanonicalWebhookField,
548
+ parseWebhookSignatureHeader,
549
+ WEBHOOK_FIELD_MAX,
550
+ WEBHOOK_ID_HEADER,
551
+ WEBHOOK_SIGNATURE_HEADER,
552
+ WEBHOOK_SIGNATURE_VERSION,
553
+ WEBHOOK_TOPIC_HEADER,
554
+ webhookHeaders,
555
+ webhookMac,
556
+ webhookSignature,
557
+ webhookSigningString,
558
+ } from './webhook-signature';
package/src/registrar.ts CHANGED
@@ -54,12 +54,20 @@ export interface PrimitiveFactory {
54
54
  export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
55
55
  (
56
56
  [
57
+ // `mutator` and not `action`, even though `Mutator extends Action`: the scan seeds `Mutator`
58
+ // in its own roots and the fixpoint refuses to overwrite a name it already holds, so the
59
+ // more specific answer wins. A `kind: 'action'` here would be the one row the scan disagrees
60
+ // with, and it would disagree silently in the direction that loses information.
61
+ { factory: 'transition', pkg: '@ultimat3/action', kind: 'mutator' },
57
62
  { factory: 'agent', pkg: '@ultimat3/ai', kind: 'action' },
58
63
  { factory: 'agentJob', pkg: '@ultimat3/ai', kind: 'job' },
59
64
  { factory: 'hive', pkg: '@ultimat3/ai', kind: 'action' },
60
65
  { factory: 'llm', pkg: '@ultimat3/ai', kind: 'action' },
61
66
  { factory: 'backfill', pkg: '@ultimat3/jobs', kind: 'job' },
67
+ { factory: 'exportRows', pkg: '@ultimat3/jobs', kind: 'job' },
62
68
  { factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
69
+ { factory: 'webhook', pkg: '@ultimat3/jobs', kind: 'job' },
70
+ { factory: 'notifier', pkg: '@ultimat3/notify', kind: 'job' },
63
71
  { factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
64
72
  ] satisfies readonly PrimitiveFactory[]
65
73
  ).map((entry) => Object.freeze(entry)),
@@ -0,0 +1,44 @@
1
+ // How much of THIS request's budget is left, and the one header that carries it to the next hop.
2
+ // The header name lives here rather than in `@ultimat3/http` because both ends need it and only
3
+ // one of them is tier 2: the reader is http's `resolveTimeoutMs`, the writer is a typed client in
4
+ // tier 0, and a second literal in the writer is a propagation that silently stops working the day
5
+ // either string is edited.
6
+
7
+ import type { Ctx } from './context';
8
+ import { tryUseContext } from './context';
9
+
10
+ /**
11
+ * A caller may SHORTEN the hop it is calling, never lengthen it — `@ultimat3/http`'s
12
+ * `resolveTimeoutMs` takes the minimum of its own configured budget and this. It had ONE reader
13
+ * and zero writers anywhere in the tree, so a 30s gateway budget that had already been spent to
14
+ * t=29 handed the next service a fresh 30s: work still running, still holding a pool slot and a
15
+ * vendor connection, 30 seconds after the caller's socket was answered `X_TIMEOUT`.
16
+ */
17
+ export const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
18
+
19
+ /**
20
+ * Milliseconds left before this context's deadline, or `undefined` when there is no deadline or
21
+ * it has already passed.
22
+ *
23
+ * `undefined` for a spent budget rather than `0`, and that is not a nicety: the far side ignores
24
+ * anything under 1ms and falls back to its OWN configured budget, so a `0` on the wire reads as
25
+ * "the caller asked for nothing" — the exact failure this header exists to prevent, one hop later.
26
+ * Whether to make a call whose budget is gone is the caller's decision (`throwIfAborted`), not
27
+ * this function's.
28
+ */
29
+ export const remainingBudgetMs = (ctx: Ctx): number | undefined => {
30
+ if (ctx.deadlineAt === null) return undefined;
31
+ const left = Math.floor(ctx.deadlineAt - ctx.now().getTime());
32
+ return left >= 1 ? left : undefined;
33
+ };
34
+
35
+ /**
36
+ * The budget header for the ambient request, or nothing. Rounded DOWN by `remainingBudgetMs`:
37
+ * a hop must never be told it has more time than the caller will wait.
38
+ */
39
+ export const budgetHeaders = (): Record<string, string> => {
40
+ const ctx = tryUseContext();
41
+ if (ctx === undefined) return {};
42
+ const left = remainingBudgetMs(ctx);
43
+ return left === undefined ? {} : { [REQUEST_TIMEOUT_HEADER]: String(left) };
44
+ };
package/src/service.ts CHANGED
@@ -8,10 +8,15 @@
8
8
  // with `defineService` is what lets `createContext` do that automatically instead of every
9
9
  // caller wiring `services: { posts: postsService(ctx) }` by hand at every call site.
10
10
 
11
- import type { Ctx, ServiceBag } from './context';
11
+ import type { CtxFacts, ServiceBag } from './context';
12
12
  import { UltimateError } from './errors';
13
13
 
14
- export type ServiceFactory<T = unknown> = (ctx: Ctx) => T;
14
+ /**
15
+ * `CtxFacts` and not `Ctx`: this factory runs INSIDE `createContext`, against a preview that
16
+ * carries no other registered service — which the paragraph above has always said and the type
17
+ * now enforces. It is also what lets `createContext` build that preview without an assertion.
18
+ */
19
+ export type ServiceFactory<T = unknown> = (ctx: CtxFacts) => T;
15
20
 
16
21
  const factories = new Map<string, ServiceFactory>();
17
22
 
@@ -48,7 +53,7 @@ export function isManagedService(name: string): boolean {
48
53
  * service yet — a factory reads the ambient actor/clock/tz, never a sibling service, so
49
54
  * factories cannot depend on one another's instances.
50
55
  */
51
- export function installedServices(ctx: Ctx): ServiceBag {
56
+ export function installedServices(ctx: CtxFacts): ServiceBag {
52
57
  if (factories.size === 0) return {};
53
58
  const bag: Record<string, unknown> = {};
54
59
  for (const [name, factory] of factories) bag[name] = factory(ctx);
@@ -0,0 +1,149 @@
1
+ // Single responsibility: the webhook wire format — the canonical string, the mac over it, the
2
+ // three headers a delivery carries, and the parse of the one header a receiver reads back.
3
+ //
4
+ // It lives HERE for the reason `timing-safe-equal.ts` does, in that file's own words: two packages
5
+ // need the identical guarantee and cannot share it any other way. `@ultimat3/jobs` (tier 3) signs
6
+ // a delivery and its boundary forbids `@ultimat3/http`; `@ultimat3/http` (tier 2) verifies one and
7
+ // may not reach tier 3. Neither is the other's dependency, so the one copy lives at the tier both
8
+ // can reach. Before this module the spelling was stated twice and held together by a hex literal
9
+ // asserted in two test files — which works and is not a single source of truth.
10
+ //
11
+ // FORMAT and never POLICY. What counts as fresh, how large a body may be, and which status a
12
+ // refusal answers with are the receiver's questions and stay in `@ultimat3/http`.
13
+
14
+ /** The format's version: the first field of the canonical string, and the signature's key. */
15
+ export const WEBHOOK_SIGNATURE_VERSION = 'v1';
16
+
17
+ export const WEBHOOK_ID_HEADER = 'x-ultimate-webhook-id';
18
+ export const WEBHOOK_TOPIC_HEADER = 'x-ultimate-webhook-topic';
19
+ export const WEBHOOK_SIGNATURE_HEADER = 'x-ultimate-webhook-signature';
20
+
21
+ /** Bounds the canonical string, the header echo and any error text built from either. */
22
+ export const WEBHOOK_FIELD_MAX = 200;
23
+
24
+ /** Fields a spreadsheet-free reader still must not let move a separator. See below. */
25
+ const SEPARATOR = 0x3a;
26
+ const DELETE = 0x7f;
27
+
28
+ /** `t=<digits>,v1=<hex>`, in either order, with nothing else accepted. */
29
+ const SIGNATURE_FIELD = /^([a-z0-9]+)=([A-Za-z0-9_-]+)$/;
30
+ /** Digits only and bounded — see `parseWebhookSignatureHeader`. */
31
+ const TIMESTAMP = /^\d{1,15}$/;
32
+
33
+ /**
34
+ * A field may not move the `:` separators. Without this rule an event id of `evt:01HZ` with topic
35
+ * `orders.paid` and an id of `evt` with topic `01HZ:orders.paid` build the SAME canonical string —
36
+ * one mac authenticating two differently-labelled deliveries, which is the sender's own signature
37
+ * under a label it never wrote. A control character is refused for a second reason: these fields
38
+ * are sent as HTTP header values, and a CR or LF in one is a header nobody wrote.
39
+ */
40
+ export function isCanonicalWebhookField(value: string): boolean {
41
+ if (value.length === 0 || value.length > WEBHOOK_FIELD_MAX) return false;
42
+ for (let index = 0; index < value.length; index += 1) {
43
+ const code = value.charCodeAt(index);
44
+ if (code === SEPARATOR || code < 0x20 || code === DELETE) return false;
45
+ }
46
+ return true;
47
+ }
48
+
49
+ export interface WebhookSigningInput {
50
+ /** The shared secret for this endpoint. Never logged, never rendered into an error. */
51
+ readonly secret: string;
52
+ /**
53
+ * When this REQUEST is signed — not when the event happened. A delivery retried three days later
54
+ * signs again at the moment it is sent, so a receiver's freshness window measures the request in
55
+ * front of it rather than the age of the fact behind it.
56
+ */
57
+ readonly timestampSeconds: number;
58
+ readonly eventId: string;
59
+ readonly topic: string;
60
+ /** The exact text the delivery sends. Serialised by the app, signed here byte for byte. */
61
+ readonly body: string;
62
+ }
63
+
64
+ /** The bytes the mac is taken over. One function, so the format has one spelling anywhere. */
65
+ export function webhookSigningString(input: WebhookSigningInput): string {
66
+ return `${WEBHOOK_SIGNATURE_VERSION}:${input.timestampSeconds}:${input.eventId}:${input.topic}:${input.body}`;
67
+ }
68
+
69
+ export interface WebhookMacInput {
70
+ readonly secret: string;
71
+ /**
72
+ * The timestamp exactly as it is spelled on the wire. A STRING and not a number, because a mac
73
+ * is over bytes: re-rendering it would make `t=01700000000` and `t=1700000000` one signature
74
+ * over two different headers.
75
+ */
76
+ readonly timestampText: string;
77
+ readonly eventId: string;
78
+ readonly topic: string;
79
+ /**
80
+ * Text on the sending side, raw BYTES on the receiving one. Identical either way — an HMAC is
81
+ * over a byte stream and `update(string)` encodes UTF-8 — and the bytes form never round-trips a
82
+ * body that is not valid UTF-8 through a decoder before the mac is taken over it.
83
+ */
84
+ readonly body: string | Uint8Array;
85
+ }
86
+
87
+ /** The hex hmac-sha256 over the canonical string. The one place either side computes one. */
88
+ export function webhookMac(input: WebhookMacInput): string {
89
+ const hasher = new Bun.CryptoHasher('sha256', input.secret);
90
+ hasher.update(
91
+ `${WEBHOOK_SIGNATURE_VERSION}:${input.timestampText}:${input.eventId}:${input.topic}:`,
92
+ );
93
+ hasher.update(input.body);
94
+ return hasher.digest('hex');
95
+ }
96
+
97
+ /**
98
+ * The `x-ultimate-webhook-signature` value: `t=<seconds>,v1=<hex hmac-sha256>`.
99
+ *
100
+ * The timestamp travels in the header AND inside the mac. Both are needed: the header is what the
101
+ * receiver measures its window against, and the copy under the mac is what stops that header being
102
+ * edited on a captured request.
103
+ */
104
+ export function webhookSignature(input: WebhookSigningInput): string {
105
+ const timestampText = String(input.timestampSeconds);
106
+ const mac = webhookMac({ ...input, timestampText });
107
+ return `t=${timestampText},${WEBHOOK_SIGNATURE_VERSION}=${mac}`;
108
+ }
109
+
110
+ /** Every header a delivery carries beyond `content-type`. One composition over the two above. */
111
+ export function webhookHeaders(input: WebhookSigningInput): Readonly<Record<string, string>> {
112
+ return {
113
+ [WEBHOOK_ID_HEADER]: input.eventId,
114
+ [WEBHOOK_TOPIC_HEADER]: input.topic,
115
+ [WEBHOOK_SIGNATURE_HEADER]: webhookSignature(input),
116
+ };
117
+ }
118
+
119
+ export interface WebhookSignatureFields {
120
+ /** As spelled on the wire — what `webhookMac` must be given. */
121
+ readonly timestampText: string;
122
+ readonly timestampSeconds: number;
123
+ readonly mac: string;
124
+ }
125
+
126
+ /**
127
+ * The header, or `undefined` for anything this format does not define. It parses and never judges:
128
+ * whether the timestamp is FRESH is the receiver's question and lives one tier up.
129
+ */
130
+ export function parseWebhookSignatureHeader(
131
+ header: string | null,
132
+ ): WebhookSignatureFields | undefined {
133
+ if (header === null) return undefined;
134
+ let timestamp: string | undefined;
135
+ let mac: string | undefined;
136
+ for (const part of header.split(',')) {
137
+ const match = SIGNATURE_FIELD.exec(part.trim());
138
+ if (match === null) return undefined;
139
+ const [, key, value] = match;
140
+ if (key === 't') timestamp = value;
141
+ else if (key === WEBHOOK_SIGNATURE_VERSION) mac = value;
142
+ }
143
+ if (timestamp === undefined || mac === undefined) return undefined;
144
+ // Digits only, and bounded. Without it `Number('nope')` is `NaN`, `NaN > toleranceMs` is FALSE,
145
+ // and a receiver's freshness window silently accepts every delivery — the one guard here whose
146
+ // failure mode is "the check does not run" rather than "the check refuses".
147
+ if (!TIMESTAMP.test(timestamp)) return undefined;
148
+ return { timestampText: timestamp, timestampSeconds: Number(timestamp), mac };
149
+ }