@ultimat3/core 12.0.0 → 14.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
@@ -450,6 +450,32 @@ Gotchas:
450
450
  where an app reads an undeclared service, which is the point, but `examples/dummy` ships one
451
451
  such read and it would land on the app gate's ratchet. Land it as its own change, alone, with a
452
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.
453
479
  - Tests that touch the registry, the lifecycle or the listener table must call
454
480
  `resetErrorCodes()` / `resetLifecycle()` / `resetListeners()`.
455
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": "12.0.0",
3
+ "version": "14.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",
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;
@@ -66,6 +108,13 @@ export interface Ctx extends CtxServices {
66
108
  readonly services: ServiceBag;
67
109
  }
68
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
+
69
118
  export interface CtxInit {
70
119
  readonly requestId?: string | undefined;
71
120
  readonly traceId?: string | undefined;
@@ -133,16 +182,17 @@ export function createContext(init: CtxInit = {}): Ctx {
133
182
  // what stops factories from depending on one another's instances. Explicit `init.services`
134
183
  // wins over an auto-installed one of the same name — a test's hand-built mock overrides the
135
184
  // real thing on purpose.
136
- const preview = Object.freeze({ ...explicit, ...fields, services: explicit }) as Ctx;
185
+ const preview: CtxFacts = Object.freeze({ ...explicit, ...fields, services: explicit });
137
186
  const services: ServiceBag = Object.freeze({ ...installedServices(preview), ...explicit });
138
187
  const ctx = {
139
188
  // Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
140
189
  // augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
141
190
  // with a framework field (`actor`, `logger`) loses — it stays reachable as
142
191
  // `ctx.services.actor`, and the context's own meaning never depends on what an app named a
143
- // service. The assertion is the one thing this package cannot prove: an augmentation
144
- // declares which services exist, only the boot code knows whether it passed them, or
145
- // 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`
146
196
  // through `ctx.posts` — this is a frozen plain object, and it stays one on purpose: a
147
197
  // get-trap proxy that threw on absent keys would also throw on `await ctx` (the runtime
148
198
  // probes `.then`), on `JSON.stringify`, and on every optional-property check.
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,
@@ -535,3 +535,24 @@ export {
535
535
  VERSION_DEFINE,
536
536
  VERSION_MANIFEST,
537
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)),
@@ -8,6 +8,7 @@
8
8
  // one place and not the other fails the build instead of quietly disagreeing at runtime.
9
9
 
10
10
  import { registerErrorCodes } from './error-codes';
11
+ import { registerErrorRetry } from './error-retry';
11
12
 
12
13
  /** Mirrors `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`. Keep the titles identical. */
13
14
  export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze({
@@ -26,3 +27,23 @@ registerErrorCodes(
26
27
  Object.entries(SCHEMA_ERROR_CODE_TITLES).map(([code, title]) => [code, { title }]),
27
28
  ),
28
29
  );
30
+
31
+ // And how each is RETRIED, here for the same tier reason the titles are here.
32
+ //
33
+ // Every one is LISTED rather than left to the default, which is the lesson `packages/jobs/src/
34
+ // errors.ts` writes up for its own six terminal webhook codes: `classifyThrown` reads an
35
+ // unregistered code as UNCLASSIFIED, so the attempt count governs and a schema refusal raised
36
+ // inside a job body burns the whole retry policy re-proving an answer no attempt can change.
37
+ //
38
+ // Measured cost before this: `@ultimat3/scraping` spent FIVE browser launches on a page carrying
39
+ // a `<div constructor="...">` — five navigations, five arrivals at a login — and dead-lettered
40
+ // reporting that the browser went away, about a browser that answered perfectly.
41
+ // `packages/scraping/src/cdp-target.ts` names the gap and says it cannot be closed from there.
42
+ //
43
+ // A value that does not match its schema does not match it on attempt five either.
44
+ registerErrorRetry({
45
+ X_VALIDATION_FAILED: 'terminal',
46
+ X_SCHEMA_UNSUPPORTED: 'terminal',
47
+ X_SCHEMA_DISCRIMINANT_INVALID: 'terminal',
48
+ X_SCHEMA_DEFAULT_UNSHAREABLE: 'terminal',
49
+ });
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
+ }