@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 +27 -0
- package/package.json +1 -1
- package/src/client-wire.ts +26 -10
- package/src/context.ts +72 -5
- package/src/index.ts +27 -1
- package/src/registrar.ts +8 -0
- package/src/request-budget.ts +44 -0
- package/src/service.ts +8 -3
- package/src/webhook-signature.ts +149 -0
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
package/src/client-wire.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a typed client puts on the wire and reads back off it: the
|
|
3
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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 })
|
|
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
|
|
131
|
-
// declares which services exist, only the boot code knows whether
|
|
132
|
-
// registered a factory for them
|
|
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 {
|
|
11
|
+
import type { CtxFacts, ServiceBag } from './context';
|
|
12
12
|
import { UltimateError } from './errors';
|
|
13
13
|
|
|
14
|
-
|
|
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:
|
|
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
|
+
}
|