@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 +26 -0
- package/package.json +1 -1
- package/src/context.ts +55 -5
- package/src/index.ts +22 -1
- package/src/registrar.ts +8 -0
- package/src/schema-error-codes.ts +21 -0
- package/src/service.ts +8 -3
- package/src/webhook-signature.ts +149 -0
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
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;
|
|
@@ -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 })
|
|
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
|
|
144
|
-
// declares which services exist, only the boot code knows whether
|
|
145
|
-
// 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`
|
|
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 {
|
|
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
|
+
}
|