@ultimat3/http 12.0.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
@@ -41,7 +41,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
41
41
  page. `type-pins.ts` holds the other half: a key on `HttpConfig` and not on `HttpConfigInput` is
42
42
  a build error, which `scripts/config-readers.ts` cannot see — that ratchet walks `AppConfig` and
43
43
  asks whether a key is READ, and this is the mirror question.
44
- - **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and
44
+ - **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx` and
45
45
  `asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set
46
46
  none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw
47
47
  `TypeError: ctx.now is not a function` on every audited action served over HTTP,
@@ -51,6 +51,46 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
51
51
  and it is a type-pin rather than a `.test.ts` because `tsconfig.json` excludes tests.
52
52
  `ctx.buildId` is core's meaning — the build this PROCESS serves; the CLIENT's claim is
53
53
  `ctx.clientBuildId`, read only by `assertBuild()`.
54
+
55
+ **`RequestContext extends Ctx` again, and this file no longer BUILDS a context — it composes
56
+ one** (`As of 2026-08-24`). `Ctx extends CtxServices`, and `CtxServices` is the seam an app
57
+ augments (`declare module '@ultimat3/core'`) to declare `ctx.posts` — so in an APP's program
58
+ every service it declared became a REQUIRED member of `createRequestContext`'s object literal,
59
+ and this file failed to compile inside `examples/dummy` with `TS2739: missing posts, orgs` while
60
+ the framework's own gate, which augments nothing, stayed green. The framework cannot set members
61
+ only the app's boot knows about.
62
+
63
+ `createRequestContext` now spreads `createContext()`'s result. Those members arrive WITH the
64
+ base, so the literal is checked in full and there is **no assertion left in this file** —
65
+ `withServices`, which existed for one release, is gone. `packages/core/src/context.ts` keeps the
66
+ framework's one irreducible `as Ctx` and its header states, with the four measured alternatives,
67
+ why it cannot be removed below a major.
68
+
69
+ Composing is also one constructor for one shape instead of two. This file used to re-derive
70
+ `clock`, `now`, the logger child, `signal`, `deadlineAt` and the service bag, so core could fix
71
+ any of them and this surface would keep the old answer — which is exactly what happened to the
72
+ bag (see the bullet below). `defineService` factories now install here too, for the same reason:
73
+ they are `createContext`'s, and this is `createContext`.
74
+
75
+ `type-pins.ts` carries `_RequestContextIsACtx`, because `asCtx` is a function body and a future
76
+ edit answering a failure there with a cast would delete the enforcement and leave the comment.
77
+ The reverse direction is FALSE by design — core's `Ctx` carries no `requestHeaders`, which is
78
+ what `assertInRequest` proves one way at runtime.
79
+ - **`defineService` used to be a job-and-CLI feature, and nothing said so** (`As of 2026-08-24`).
80
+ Two halves, and together they made services unreachable on the surface an app spends its life
81
+ on. This file built its own service bag from `RequestContextInit.services` alone and never
82
+ called core's `installedServices()`; `pipeline.ts:224` passes NO `services` at all. So a
83
+ `defineService('posts', …)` an app registered at boot was installed for a job, a task and a CLI
84
+ command and for nothing else — over HTTP `ctx.services` was `{}` and `useService('posts')` threw
85
+ `X_SERVICE_MISSING`. And the bag, even when one was passed, was never spread ONTO the context,
86
+ so `ctx.posts` — the spelling `docs/architecture/15-adding-a-feature.md` writes in its worked
87
+ example — read `undefined` beside a populated `ctx.services`.
88
+
89
+ Composing `createContext` fixes both at once, which is the argument for composing: the installer
90
+ is core's and so is the spread. Services go on FIRST so a service an app named `actor` or
91
+ `logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the
92
+ context's meaning never depends on what an app named a service. `context.test.ts` pins the
93
+ factory install, the spread and the collision order.
54
94
  - **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
55
95
  resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
56
96
  parsed by a stage arrived one frame after the span's context was already frozen: the caller's
@@ -124,6 +164,38 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
124
164
  `error-map` stage is the one call site that can see the config, and every degraded `problem()` in
125
165
  the tail must stay opaque. The real text is not lost — it is the log field and the error report,
126
166
  both keyed by the request id the caller was given.
167
+ - **The problem document carries the ISSUE LIST, and the opacity rule applies to it**
168
+ (`As of 2026-08-24`). `ProblemDocument.issues` is a top-level extension member — RFC 9457 §3.2
169
+ puts extension members at the document root and every Ultimate extension already is one
170
+ (`code`, `cause`, `fix`, `docs`, `requestId`); there is no bag. `@ultimat3/action` has attached
171
+ the list to `meta.issues` since `InputInvalidError` grew its third parameter and **nothing
172
+ carried it**, so every app in this framework recovered per-field form errors by splitting
173
+ `cause` on `'; '` — guesswork the moment a message contains the separator.
174
+
175
+ Four rules, and the third is the one most likely to be dropped by a later edit.
176
+ `issuesOf` is TOTAL and module-private, written in `retryAfterOf`'s shape and for its reason:
177
+ `meta` is a property read on a value this package did not build, in the frame that decides what
178
+ the caller sees. It is **all-or-nothing** — a client that finds `issues` uses it INSTEAD of
179
+ `cause`, so one unreadable entry drops the whole list back to the prose line rather than
180
+ shipping a subset, which would be a rejection the user never sees and a form reporting itself
181
+ valid. The member is **absent** when there is none, never `undefined` and never `[]`:
182
+ `JSON.stringify` drops an `undefined`, but this interface is read directly by `error-page.ts`
183
+ and by tests, and `[]` claims "validated clean" about a request that was just refused — which
184
+ the first implementation of this reader emitted, because `Array.isArray([])` is true and the
185
+ loop simply does not run. A list past `MAX_PROBLEM_ISSUES` (100) is dropped WHOLE for the same
186
+ all-or-nothing reason, and because `@ultimat3/action`'s `issuesFromWire` bounds it identically
187
+ on arrival: sending it is a body that costs the wire and answers nothing. That package is tier 3,
188
+ so the number is restated here and pinned on this side. And it is **dropped under exactly the condition
189
+ that blanks `title`/`detail`/`cause`** — an issue list on an unclassified 5xx is precisely the
190
+ internal detail `INTERNAL_CAUSE` exists to withhold, because it names the fields and the
191
+ expectations of something the caller was never meant to see inside. `X_INPUT_INVALID` is a
192
+ declared 4xx and so is never opaque.
193
+
194
+ `received` is forced to `''` and every entry is rebuilt member by member, never spread. Not
195
+ redundancy with `toValidationIssues`, which forces the same thing: this is the boundary where
196
+ the value LEAVES the process, a conforming library's own issue object is first-class here and
197
+ routinely carries the rejected value, and `@ultimat3/schema`'s `describeValue` exists because a
198
+ password-strength rule once wrote mistyped passwords into the log index.
127
199
  - **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
128
200
  `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
129
201
  `error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
@@ -431,6 +503,33 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
431
503
  is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
432
504
  caller who built their own may have meant their own numbers. A limiter that declares no table
433
505
  is refused too — what cannot be shown to hold is not assumed to hold.
506
+ - **`verifyWebhookSignature` is a FUNCTION, never a pipeline stage** (`As of 2026-08-24`). The
507
+ secret is per SENDER, and only the route knows which sender it is serving; a stage would need one
508
+ secret for the whole app or a table this package has no business holding. It reads the body
509
+ through core's `readWithinLimit` — the same counting reader `UltimateRequest.#read` uses — so it
510
+ composes with the cap rather than defeating it, and it answers the raw text so the caller parses
511
+ the bytes that were signed rather than a re-serialisation of them.
512
+
513
+ The order inside it is load-bearing: the mac is checked BEFORE the freshness window, so
514
+ `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old* and never *unreadable and old* — an
515
+ operator reading it goes to a clock or a replay, which is the only reason the second code exists.
516
+ The window is `Math.abs`, both directions: a sender whose clock runs ahead is the same replay
517
+ window pointed the other way, and accepting the future half doubles it. The timestamp is parsed
518
+ digits-only because `Number('nope')` is `NaN` and `NaN > toleranceMs` is FALSE — the one guard
519
+ whose failure mode is "the check does not run". `:` is refused in the id and the topic because
520
+ one mac over `v1:t:evt:01HZ:orders.paid:<body>` would otherwise authenticate two different
521
+ id/topic splits. The comparison is `timingSafeEqual` and may never become `===`; `bun run
522
+ secret-compare` is the mechanical half and `mac`/`signature` are names it reads.
523
+
524
+ **The FORMAT is `@ultimat3/core`'s and is not re-declared here** (`As of 2026-08-24`).
525
+ `packages/core/src/webhook-signature.ts` owns the canonical string, the mac and the parse;
526
+ this file owns the POLICY — what counts as fresh, how large a body may be, and which refusal a
527
+ receiver answers with. It shipped for one release as two implementations, here and in
528
+ `@ultimat3/jobs`, held together by a hex literal asserted in two test files: this package is
529
+ tier 2 and may not reach tier 3, that one's boundary forbids `http`, so the one copy lives at
530
+ the tier both can reach — the argument `timing-safe-equal.ts` makes for itself. The two literal
531
+ vectors stay until `scripts/webhook-round-trip.test.ts` replaces them. **Never re-declare the
532
+ canonical string here.**
434
533
  - Never throw a bare `Error` — use a factory from `errors.ts`.
435
534
  - No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
436
535
  - Health endpoints answer outside the pipeline, on purpose.
@@ -456,12 +555,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
456
555
  | `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
457
556
  | `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
458
557
  | `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
459
- | `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document, the three terminal lines |
558
+ | `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines |
460
559
  | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
461
560
  | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
462
561
  | `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
463
562
  | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
464
- | `context.ts` | `RequestContext` + the single `Ctx` adapter (`asCtx`) + the inbound-header readers |
563
+ | `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `createContext`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
465
564
  | `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
466
565
  | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
467
566
  | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
@@ -473,6 +572,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
473
572
  | `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
474
573
  | `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
475
574
  | `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
575
+ | `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import |
476
576
  | `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
477
577
  | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
478
578
  | `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
package/README.md CHANGED
@@ -113,6 +113,16 @@ thing that knows where its counters live, and a framework that inferred the answ
113
113
  environment would get it wrong on the first deployment that scaled differently.
114
114
 
115
115
  ```ts
116
+ import {
117
+ createServer,
118
+ defineHttpConfig,
119
+ type RateLimitStore,
120
+ type Route,
121
+ } from '@ultimat3/http';
122
+
123
+ declare const routes: readonly Route[];
124
+ declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
125
+
116
126
  createServer({
117
127
  routes,
118
128
  config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
@@ -234,6 +244,8 @@ flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** — a ro
234
244
  cannot forget to declare its auth posture.
235
245
 
236
246
  ```ts
247
+ import { createServer, defineHttpConfig, json } from '@ultimat3/http';
248
+
237
249
  const handle = createServer({
238
250
  routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
239
251
  handler: (req) => json({ id: req.param('id') }) }],
@@ -245,11 +257,56 @@ const handle = createServer({
245
257
  Static paths are registered in Bun's native `routes` table; param/wildcard paths fall
246
258
  through to `fetch`. Method resolution stays ours so a 405 still carries problem+json.
247
259
 
260
+ ## Inbound webhooks
261
+
262
+ `verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
263
+ mechanism. It is a plain function and not a pipeline stage, because the secret is **per sender**
264
+ and only the route knows which one applies.
265
+
266
+ ```ts
267
+ // apps/web/api/webhooks/partner/route.ts
268
+ import { verifyWebhookSignature } from '@ultimat3/http';
269
+
270
+ declare const env: { readonly PARTNER_WEBHOOK_SECRET: string }; // the app's defineEnv() result
271
+ // The seen-set and the dispatch are the app's — this package has nowhere to keep either.
272
+ declare function alreadyHandled(eventId: string): Promise<boolean>;
273
+ declare function handle(topic: string, payload: unknown, eventId: string): Promise<void>;
274
+
275
+ export async function POST(request: Request): Promise<Response> {
276
+ const { eventId, topic, body } = await verifyWebhookSignature(request, {
277
+ secret: env.PARTNER_WEBHOOK_SECRET,
278
+ });
279
+ // Parse the bytes that were SIGNED. Never `request.json()` — the stream is spent, and a
280
+ // re-serialisation would not be the bytes the mac covers.
281
+ const payload: unknown = JSON.parse(body);
282
+ if (await alreadyHandled(eventId)) return new Response(null, { status: 200 });
283
+ await handle(topic, payload, eventId);
284
+ return new Response(null, { status: 202 });
285
+ }
286
+ ```
287
+
288
+ | Property | How |
289
+ |---|---|
290
+ | constant time | the mac is compared with `@ultimat3/core`'s `timingSafeEqual`, never `===` — where two macs first differ is exactly what a timing oracle forges one byte at a time |
291
+ | a replay expires | the signature's timestamp is checked against `toleranceMs` (5 minutes by default), **both** directions — a sender whose clock runs ahead is the same window pointed the other way |
292
+ | a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one |
293
+ | moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac |
294
+ | the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload |
295
+ | the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay |
296
+
297
+ `X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is
298
+ well formed and carried a credential, and the credential is what failed. Neither triggers the
299
+ sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
300
+
301
+ The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
302
+ canonical string is stated in both and pinned by one literal vector asserted in both test files.
303
+
248
304
  ## Errors
249
305
 
250
306
  `X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
251
307
  · `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
252
- · `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED`
308
+ · `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED` · `X_WEBHOOK_SIGNATURE_INVALID`
309
+ · `X_WEBHOOK_SIGNATURE_STALE`
253
310
 
254
311
  One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
255
312
  overlay — so the `code`/`cause`/`fix` strings can never diverge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "12.0.0",
35
- "@ultimat3/i18n": "12.0.0",
36
- "@ultimat3/schema": "12.0.0",
37
- "@ultimat3/time": "12.0.0"
34
+ "@ultimat3/core": "13.0.0",
35
+ "@ultimat3/i18n": "13.0.0",
36
+ "@ultimat3/schema": "13.0.0",
37
+ "@ultimat3/time": "13.0.0"
38
38
  }
39
39
  }
package/src/context.ts CHANGED
@@ -6,11 +6,11 @@ import {
6
6
  anonymousActor,
7
7
  type Clock,
8
8
  type Ctx,
9
+ createContext,
9
10
  isAnonymous,
10
11
  type Logger,
11
12
  traceId as newTraceId,
12
13
  type Role,
13
- logger as rootLogger,
14
14
  type ServiceBag,
15
15
  systemClock,
16
16
  useContext,
@@ -28,15 +28,23 @@ import type { CacheHint, RedirectIntent } from './response';
28
28
  import type { Route, RouteParams } from './router';
29
29
 
30
30
  /**
31
- * The per-request context, and — through `asCtx` — core's `Ctx` itself. Every member `Ctx`
32
- * declares is declared here and SET by `createRequestContext`, because `asCtx` used to be
33
- * `as unknown as Ctx` over an object missing five of them (`clock`, `now`, `logger`, `signal`,
34
- * `services`). The assertion type-checked and every reader threw at runtime: `ctx.now()` in
35
- * `@ultimat3/action`'s audit trail, `useService()`, `throwIfAborted()`. The cast is gone, so
36
- * a member core adds is a build error in this file until it is set. The `extends` is what makes
37
- * that true rather than aspirational and it carries `CtxServices`' index signature, which is
38
- * what an app augments for `ctx.posts`; `noPropertyAccessFromIndexSignature` keeps `ctx.typo` a
39
- * build error all the same.
31
+ * The per-request context, and — through `asCtx` — core's `Ctx` itself.
32
+ *
33
+ * `extends Ctx` again, `As of 2026-08-24`, and it is safe again for one reason: this file no
34
+ * longer BUILDS a `Ctx`, it composes one. `Ctx extends CtxServices`, an app augments
35
+ * `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then
36
+ * became a required member of every context literal in the framework this file failed to
37
+ * compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own
38
+ * gate, which augments nothing, stayed green. `createRequestContext` now spreads
39
+ * `createContext()`'s result, so the members only an app's boot can supply arrive with it and the
40
+ * literal below is checked in full.
41
+ *
42
+ * The `extends` is therefore back to doing what it was always claimed to do: a member core adds
43
+ * to `Ctx` is set here — by `base` — or this file does not compile. `asCtx` is the identity
44
+ * function and never an assertion; `as unknown as Ctx` is what it used to be, over an object
45
+ * missing `clock`, `now`, `logger`, `signal` and `services`, and every reader threw at runtime.
46
+ * `CtxServices`' index signature is what an app augments; `noPropertyAccessFromIndexSignature`
47
+ * keeps `ctx.typo` a build error all the same.
40
48
  */
41
49
  export interface RequestContext extends Ctx {
42
50
  /** `performance.now()` at accept time; used for the server-timing header. */
@@ -145,12 +153,6 @@ export interface RequestContextInit {
145
153
  readonly services?: ServiceBag;
146
154
  }
147
155
 
148
- /**
149
- * One signal for every context built without one, so "no cancellation here" costs no allocation
150
- * and `ctx.signal.aborted` is still a read rather than a `TypeError`. The same shape core uses.
151
- */
152
- const NEVER_ABORTED: AbortSignal = new AbortController().signal;
153
-
154
156
  export const createRequestContext = (init: RequestContextInit): RequestContext => {
155
157
  const clock = init.clock ?? systemClock;
156
158
  const requestId = init.requestId ?? uuid(clock);
@@ -158,40 +160,62 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
158
160
  // collector rejects the span that carries one — while the log lines beside it, which quote the
159
161
  // same field, look fine. Two ids for one request that cannot be joined.
160
162
  const traceId = init.traceId ?? newTraceId();
163
+ // COMPOSED from core's constructor rather than built beside it, and that is what deletes the
164
+ // last cast in this file. `createContext` returns a `Ctx` that already carries the app's
165
+ // `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot
166
+ // could supply — and the return below is checked in full, with nothing asserted anywhere.
167
+ //
168
+ // It is also one constructor for one shape instead of two. This file used to re-derive `clock`,
169
+ // `now`, the logger child, `signal`, `deadlineAt` and the service bag itself, so core could fix
170
+ // any of them and the HTTP surface would keep the old answer — which is exactly what happened to
171
+ // the bag: core has spread services ONTO the context since it shipped and this file never did,
172
+ // so an app declaring `ctx.posts` the documented way read `undefined` over HTTP while
173
+ // `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable.
174
+ //
175
+ // `defineService` factories now install on this surface too, for the same reason: they are
176
+ // `createContext`'s and this is `createContext`.
177
+ const base = createContext({
178
+ requestId,
179
+ traceId,
180
+ role: init.role,
181
+ // The build this PROCESS serves. The client's claim goes to `clientBuildId` below, where only
182
+ // `assertBuild()` reads it — the two shared this name until `asCtx` was checked.
183
+ buildId: init.config.buildId ?? 'dev',
184
+ // What the request gets before the `locale` stage runs, and what it keeps if the stage is
185
+ // never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
186
+ locale: localeConfig().fallback,
187
+ tz: timeConfig().defaultZone,
188
+ clock,
189
+ ...(init.logger === undefined ? {} : { logger: init.logger }),
190
+ // Absent means a request nothing can cancel, which is core's `neverAborted` — the same shape
191
+ // this file kept its own singleton for.
192
+ ...(init.signal === undefined ? {} : { signal: init.signal }),
193
+ // `null` and "not set" are one fact to core, whose own field is `number | null`.
194
+ ...(init.deadlineAt === undefined || init.deadlineAt === null
195
+ ? {}
196
+ : { deadlineAt: init.deadlineAt }),
197
+ ...(init.services === undefined ? {} : { services: init.services }),
198
+ });
161
199
  return {
200
+ ...base,
201
+ // Everything below is either this package's own or a core member the PIPELINE rewrites: the
202
+ // mutable slots are re-declared here so a stage can write them, and they must therefore be
203
+ // this object's own properties rather than the frozen base's.
162
204
  requestId,
163
205
  traceId,
164
206
  parentSpanId: init.parentSpanId ?? null,
165
207
  startedAt: performance.now(),
166
208
  url: init.url,
167
209
  method: init.method.toUpperCase(),
168
- role: init.role,
169
210
  config: init.config,
170
211
  ip: init.ip ?? null,
171
212
  https: init.https ?? init.url.protocol === 'https:',
172
213
  peer: init.peer ?? null,
173
214
  headers: new Headers(),
174
215
  requestHeaders: new Headers(init.requestHeaders),
175
- // The build this PROCESS serves, resolved the way core resolves it. The client's claim goes
176
- // to `clientBuildId` below, where only `assertBuild()` reads it.
177
- buildId: init.config.buildId ?? 'dev',
178
- clock,
179
- now: () => clock.now(),
180
- // A child, so `ctx.logger` carries the ids even where core's ALS injector cannot see the
181
- // context — a callback that outlived the request scope, a logger passed to a driver.
182
- logger: (init.logger ?? rootLogger).child({ requestId, traceId }),
183
- signal: init.signal ?? NEVER_ABORTED,
184
- deadlineAt: init.deadlineAt ?? null,
185
- // Frozen and explicit. `defineService` factories are NOT installed here: core does not
186
- // export the installer, so the honest answer for a service nothing passed is
187
- // `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than
188
- // the `TypeError: undefined is not an object` a missing bag produced.
189
- services: Object.freeze({ ...(init.services ?? {}) }),
190
216
  params: {},
191
217
  route: undefined,
192
218
  actor: anonymousActor(),
193
- // What the request gets before the `locale` stage runs, and what it keeps if the stage is
194
- // never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
195
219
  locale: localeConfig().fallback,
196
220
  tz: timeConfig().defaultZone,
197
221
  clientBuildId: null,
@@ -3,6 +3,7 @@
3
3
  // 500-line ceiling — that file answers "what status is this code", one closed table, and this one
4
4
  // answers "what does a reader see", which is three audiences and one opacity rule.
5
5
  import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
6
+ import type { ValidationIssue } from '@ultimat3/schema';
6
7
  import { declaredStatusFor, statusFor } from './error-map';
7
8
  import { HTTP_ERROR_TITLES } from './errors';
8
9
 
@@ -102,6 +103,82 @@ export function retryAfterOf(error: unknown): number | undefined {
102
103
  }
103
104
  }
104
105
 
106
+ /**
107
+ * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
108
+ * same bound `@ultimat3/action`'s `issuesFromWire` applies on arrival, restated because that
109
+ * package is tier 3 and this one is tier 2 — `error-facts.test.ts` pins the number on this side.
110
+ */
111
+ const MAX_PROBLEM_ISSUES = 100;
112
+
113
+ /**
114
+ * The rejections a validation failure carried, addressed by path — or `undefined`.
115
+ *
116
+ * `@ultimat3/action` attaches the list to `meta.issues` (`InputInvalidError`'s third parameter),
117
+ * and until this reader existed nothing carried it across the wire: a client rendering a form
118
+ * recovered per-field errors by splitting `cause` on `'; '`, which is guesswork the moment a
119
+ * message contains the separator.
120
+ *
121
+ * Total, for `retryAfterOf`'s reason directly above: `meta` is a property read on a value this
122
+ * package did not build, in the frame that decides what the caller sees.
123
+ *
124
+ * ALL-OR-NOTHING, and that is the load-bearing rule. A client that finds `issues` uses it INSTEAD
125
+ * of `cause`, so a partly-read list is a rejection the user never sees and a form that reports
126
+ * itself valid when it is not. One unreadable entry drops the whole list back to the prose line.
127
+ *
128
+ * Every entry is rebuilt MEMBER BY MEMBER and `received` is forced empty — never a spread. Not
129
+ * redundancy with `toValidationIssues`, which forces the same thing today: this is the boundary
130
+ * where the value leaves the process, and a future producer of `meta.issues` need not have gone
131
+ * through that helper. A conforming library's own issue object is first-class in this framework
132
+ * and routinely carries the rejected VALUE; `packages/schema/src/describe-value.ts` exists because
133
+ * a password-strength rule once wrote mistyped passwords into the log index.
134
+ *
135
+ * Module-private, unlike `retryAfterOf`: that one has a second caller (`stages.ts` writes it onto
136
+ * the header) and this one has exactly one, `toProblem`. An exported reader nobody outside calls
137
+ * is a public API that promises support it has never been asked for.
138
+ */
139
+ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
140
+ if (typeof error !== 'object' || error === null) return undefined;
141
+ try {
142
+ const meta: unknown = (error as Record<string, unknown>)['meta'];
143
+ if (typeof meta !== 'object' || meta === null) return undefined;
144
+ const raw: unknown = (meta as Record<string, unknown>)['issues'];
145
+ // EMPTY is `undefined`, never `[]`. `Array.isArray([])` is true and the loop below would
146
+ // simply not run, so an empty list reached the document as `issues: []` — which tells a client
147
+ // "we validated and found nothing wrong" about a request that was just refused.
148
+ //
149
+ // TOO LONG is `undefined` too, and dropped WHOLE rather than truncated: a subset is the
150
+ // silent-drop this reader refuses everywhere else. The typed client bounds the same list at
151
+ // `MAX_WIRE_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`),
152
+ // so sending it is a body that costs the wire and answers nothing — and that package is tier 3,
153
+ // so the number is restated here rather than imported.
154
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PROBLEM_ISSUES) {
155
+ return undefined;
156
+ }
157
+ const issues: ValidationIssue[] = [];
158
+ for (const entry of raw as readonly unknown[]) {
159
+ if (typeof entry !== 'object' || entry === null) return undefined;
160
+ const fields = entry as Record<string, unknown>;
161
+ const path: unknown = fields['path'];
162
+ const message: unknown = fields['message'];
163
+ const expected: unknown = fields['expected'];
164
+ // `path` and `message` are what a form binding addresses a control by and what it renders;
165
+ // an entry missing either is not usable, and a usable subset beside an unusable one is the
166
+ // silent-drop this list refuses.
167
+ if (typeof path !== 'string' || typeof message !== 'string') return undefined;
168
+ issues.push({
169
+ path,
170
+ expected: typeof expected === 'string' ? expected : message,
171
+ // Forced, never copied. See the paragraph above.
172
+ received: '',
173
+ message,
174
+ });
175
+ }
176
+ return issues;
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
105
182
  /**
106
183
  * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
107
184
  * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
@@ -127,6 +204,16 @@ export interface ProblemDocument {
127
204
  readonly fix: string;
128
205
  readonly docs: string;
129
206
  readonly requestId: string | undefined;
207
+ /**
208
+ * The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an
209
+ * extension bag: RFC 9457 §3.2 puts extension members at the document root, and every Ultimate
210
+ * extension already is one (`code`, `cause`, `fix`, `docs`, `requestId`).
211
+ *
212
+ * ABSENT when there are none — never `undefined`, never `[]`. `JSON.stringify` drops an
213
+ * `undefined` member, but this interface is read directly by `error-page.ts` and by tests, and
214
+ * `[]` says "validated clean", which is a different and false claim.
215
+ */
216
+ readonly issues?: readonly ValidationIssue[] | undefined;
130
217
  }
131
218
 
132
219
  /** The title a caller gets for a failure the framework cannot name. */
@@ -165,6 +252,11 @@ export const toProblem = (
165
252
  ): ProblemDocument => {
166
253
  const facts = factsOf(error);
167
254
  const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
255
+ // Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list
256
+ // on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to
257
+ // withhold — it names the fields and the expectations of something the caller was never meant to
258
+ // see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
259
+ const issues = opaque ? undefined : issuesOf(error);
168
260
  return {
169
261
  type: problemTypeFor(facts.code),
170
262
  title: opaque ? INTERNAL_TITLE : facts.title,
@@ -176,6 +268,7 @@ export const toProblem = (
176
268
  fix: facts.fix,
177
269
  docs: facts.docs,
178
270
  requestId: meta.requestId,
271
+ ...(issues === undefined ? {} : { issues }),
179
272
  };
180
273
  };
181
274
 
package/src/error-map.ts CHANGED
@@ -69,6 +69,15 @@ export const ERROR_STATUS = {
69
69
  // 403 and never 401: the caller IS authenticated — that is what makes the forged write work —
70
70
  // so a 401 would send a signed-in user to a sign-in page they are already past.
71
71
  X_CSRF_BLOCKED: 403,
72
+ // 401 for both, and never 400: an inbound webhook is well formed and carries a CREDENTIAL — a
73
+ // timestamped hmac over its own bytes — so what failed is authentication, not the request. Never
74
+ // 403 either, which means an authenticated caller was refused, and there is no authenticated
75
+ // caller here. Two codes rather than one because the repairs differ and a sender's dashboard
76
+ // shows the status: `INVALID` is the wrong secret or a rewritten body, `STALE` is a skewed clock
77
+ // or a delivery being replayed off a capture. Neither triggers `signInRedirect`, which keys on
78
+ // `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get.
79
+ X_WEBHOOK_SIGNATURE_INVALID: 401,
80
+ X_WEBHOOK_SIGNATURE_STALE: 401,
72
81
  // @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
73
82
  // the primitive declared. 400 because that is what the published OpenAPI operation promises for
74
83
  // it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
@@ -157,6 +166,29 @@ export const ERROR_STATUS = {
157
166
  X_AGGREGATE_UNSUPPORTED: 500,
158
167
  X_AGGREGATE_MIXED_CURRENCY: 500,
159
168
  X_APPROXIMATE_COUNT_FILTERED: 500,
169
+ // The two search refusals, 500 for the reason the three above are: nothing the caller sends
170
+ // changes either answer. An entity with no searchable column needs a `searchable()` on one, and
171
+ // a driver that cannot answer a full-text match needs the Postgres one — both are edits to the
172
+ // app, and both carry a `fix:` that an unmapped 5xx would blank (`toProblem` replaces an
173
+ // undeclared code's cause with `INTERNAL_CAUSE`).
174
+ X_SEARCH_UNDECLARED: 500,
175
+ X_SEARCH_IN_MEMORY: 500,
176
+ // The three state-machine refusals, and they are deliberately THREE statuses rather than one:
177
+ // the machine says the transition does not exist, the row says it is somewhere else, or the
178
+ // column says there is no machine at all — three different readers and three different repairs.
179
+ //
180
+ // 422 and not 400: the request is well formed and its schema passed. The transition the caller
181
+ // named is not one this machine has, which is the same shape as `X_INVARIANT_VIOLATED` above and
182
+ // takes its status. Refused before any statement opens a connection, so nothing was written.
183
+ X_STATE_TRANSITION_ILLEGAL: 422,
184
+ // 409, the lost update caught. The row moved between the read the caller decided on and the
185
+ // write it asked for — nothing is wrong with either, and the repair is re-read and retry, which
186
+ // is precisely what a 409 tells a client to do. A 422 would say "your request is unusable",
187
+ // which is false: the identical request succeeds a moment later.
188
+ X_STATE_CONFLICT: 409,
189
+ // 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the
190
+ // app has not written, and no request changes that.
191
+ X_STATE_UNDECLARED: 500,
160
192
  // @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
161
193
  // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
162
194
  // same event one layer up — is 409 above; a foreign key rides with it because both halves of it
@@ -176,6 +208,46 @@ export const ERROR_STATUS = {
176
208
  // it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
177
209
  // closed one, and a code with no row is a 500 anyway.
178
210
  X_ACTION_JOB_UNBRIDGED: 500,
211
+ // Every `X_WEBHOOK_*` below is OUTBOUND and is thrown inside a worker: `ROLE=worker` opens no
212
+ // HTTP port, so none of them ever answers a request. The rows exist for the reason
213
+ // `X_ACTION_JOB_UNBRIDGED`'s does — this table is the closed one, and a code with no row is a
214
+ // 500 anyway. The INBOUND pair (`X_WEBHOOK_SIGNATURE_*`, 401) is @ultimat3/http's and sits with
215
+ // the rest of this package's codes above; these are the ones a delivery ends on.
216
+ X_WEBHOOK_ENDPOINT_UNKNOWN: 500,
217
+ X_WEBHOOK_ENDPOINT_INVALID: 500,
218
+ X_WEBHOOK_ENDPOINT_DISABLED: 500,
219
+ X_WEBHOOK_EVENT_UNKNOWN: 500,
220
+ X_WEBHOOK_EVENT_INVALID: 500,
221
+ X_WEBHOOK_DELIVERY_FAILED: 500,
222
+ X_WEBHOOK_DELIVERY_THROTTLED: 500,
223
+ X_WEBHOOK_DELIVERY_REJECTED: 500,
224
+ // Same class again: an export pass runs in a worker, and both codes refuse the DECLARATION —
225
+ // a `row()` that answers columns nobody declared, and a page too big to hold. Neither is
226
+ // anything a caller sent.
227
+ X_EXPORT_ROW_INVALID: 500,
228
+ X_EXPORT_PART_TOO_LARGE: 500,
229
+ // @ultimat3/notify — five 500s and one 502, and the split is who failed.
230
+ //
231
+ // The five are the app's own declaration: a notifier with no channels, one channel named twice,
232
+ // a digest window on a bulk channel, a store nothing installed, and a fan-out past the per-run
233
+ // ceiling. Every `fix:` on those five names a code edit or a boot call, so nothing a caller
234
+ // sends changes any of them — `X_NOTIFY_FANOUT_TOO_WIDE` is the only one a request can even
235
+ // INFLUENCE (an action that notifies a whole org), and the repair is still `bulkChannel()` or a
236
+ // paged `backfill()`, never the request.
237
+ X_NOTIFY_CHANNELS_EMPTY: 500,
238
+ X_NOTIFY_CHANNEL_DUPLICATE: 500,
239
+ X_NOTIFY_FANOUT_TOO_WIDE: 500,
240
+ X_NOTIFY_STORE_MISSING: 500,
241
+ X_NOTIFY_DIGEST_UNSUPPORTED: 500,
242
+ // 502, and it is the one row on this table that answers for somebody else's server. This code
243
+ // WRAPS a provider rejection — `NotifyDeliveryFailedError` takes the caught value and renders it
244
+ // — so the thing that failed is the channel's upstream, not this process. It is thrown inside a
245
+ // job step today (`x jobs show <notifier> --json` is its own `fix:`), so nothing reaches a
246
+ // request and the number is unobservable either way; the row is chosen for the day that stops
247
+ // being true, and the asymmetry decides it. A wrong 502 costs nothing. A wrong 500 pages the
248
+ // on-call for an email provider's outage, because `stages.ts` reports every `status >= 500` to
249
+ // the error monitor — which is the failure this whole table exists to stop.
250
+ X_NOTIFY_DELIVERY_FAILED: 502,
179
251
  // @ultimat3/policy
180
252
  X_POLICY_MISSING: 500,
181
253
  X_PERMISSION_UNKNOWN: 500,
@@ -229,6 +301,22 @@ export const ERROR_STATUS = {
229
301
  // 404, deliberately NOT 403: the org check fires before anything is read, so answering
230
302
  // "forbidden" would confirm that a key exists to the one caller who must not learn it.
231
303
  X_STORAGE_ORG_MISMATCH: 404,
304
+ // @ultimat3/ui — a form control whose `name` is not a usable field path. The owning slice argued
305
+ // for NO ROW, on the grounds that this is a render-time developer error that can never reach
306
+ // HTTP, and the argument is right about the code and wrong about the table.
307
+ //
308
+ // `scripts/error-map-backlog.ts` is the only "no row" this table has, and its own header says
309
+ // what an entry there means: "NOT a claim that the code can never cross HTTP … a claim that
310
+ // nobody has decided yet", with the ratchet promising only that the undecided set never grows.
311
+ // This code HAS been decided, so a pin would record the opposite of what is known and grow the
312
+ // one list that may not grow.
313
+ //
314
+ // So it takes the answer every other decided-and-unreachable code takes — `X_CORS_CONFIG_INVALID`,
315
+ // `X_ACTION_JOB_UNBRIDGED`, `X_RATE_LIMIT_NOT_SHARED`. The row is NOT a claim that it reaches a
316
+ // request. A code with no row already answers 500 (`DEFAULT_STATUS`); the row changes nothing at
317
+ // runtime and makes that answer a reviewed one instead of an accident, which is the whole reason
318
+ // this table is closed.
319
+ X_UI_FORM_PATH_INVALID: 500,
232
320
  // @ultimat3/mail
233
321
  // The deployment configured no transport. It reaches a caller only through an inline
234
322
  // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
package/src/errors.ts CHANGED
@@ -34,6 +34,8 @@ export const HTTP_OWNED_ERROR_CODES = [
34
34
  'X_TRUST_PROXY_UNSET',
35
35
  'X_OVERLOADED',
36
36
  'X_CSRF_BLOCKED',
37
+ 'X_WEBHOOK_SIGNATURE_INVALID',
38
+ 'X_WEBHOOK_SIGNATURE_STALE',
37
39
  ] as const;
38
40
 
39
41
  /**
@@ -88,6 +90,8 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
88
90
  X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
89
91
  X_OVERLOADED: 'in-flight requests are at the configured ceiling',
90
92
  X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
93
+ X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
94
+ X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
91
95
  };
92
96
 
93
97
  // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
@@ -379,3 +383,37 @@ export const requestTimedOut = (method: string, pathname: string, timeoutMs: num
379
383
  fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/',
380
384
  meta: { timeoutMs },
381
385
  });
386
+
387
+ /**
388
+ * The inbound delivery is not signed by the holder of this route's secret — a wrong secret, a
389
+ * body something rewrote in transit, or a header this format does not define.
390
+ *
391
+ * 401 rather than 400: the request is well formed and carried a CREDENTIAL, and the credential is
392
+ * what failed. Rather than 403, which means an authenticated caller was refused, and there is no
393
+ * authenticated caller here. `reason` names only what the framework chose — never the signature
394
+ * that arrived, never the secret, and never the body — because a `cause` reaches both the caller
395
+ * and the log store, and a credential in either is a leak wearing a diagnostic's clothes.
396
+ */
397
+ export const webhookSignatureInvalid = (pathname: string, reason: string): HttpError =>
398
+ new HttpError({
399
+ code: 'X_WEBHOOK_SIGNATURE_INVALID',
400
+ cause: `${pathname} refused an inbound webhook: ${reason}`,
401
+ fix: 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })',
402
+ });
403
+
404
+ /**
405
+ * Signed correctly, and outside the replay window. Its own code because the repair is a different
406
+ * one: a sender's clock, or a delivery being replayed off a capture. Same 401 — the credential is
407
+ * a TIMESTAMPED one, and this is the expiry half of it.
408
+ */
409
+ export const webhookSignatureStale = (
410
+ pathname: string,
411
+ skewMs: number,
412
+ toleranceMs: number,
413
+ ): HttpError =>
414
+ new HttpError({
415
+ code: 'X_WEBHOOK_SIGNATURE_STALE',
416
+ cause: `${pathname} received a valid signature ${skewMs}ms from this clock, and the window is ${toleranceMs}ms`,
417
+ fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
418
+ meta: { skewMs, toleranceMs },
419
+ });
package/src/index.ts CHANGED
@@ -2,6 +2,16 @@
2
2
  // listed here is an implementation detail and may change without a major bump.
3
3
 
4
4
  export type { RenderMode } from '@ultimat3/core';
5
+ // The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
6
+ // the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
7
+ // one, and neither may import the other. Re-exported here so a receiver route needs one import.
8
+ export {
9
+ isCanonicalWebhookField,
10
+ WEBHOOK_ID_HEADER,
11
+ WEBHOOK_SIGNATURE_HEADER,
12
+ WEBHOOK_SIGNATURE_VERSION,
13
+ WEBHOOK_TOPIC_HEADER,
14
+ } from '@ultimat3/core';
5
15
  export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
6
16
  export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
7
17
  export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
@@ -77,6 +87,8 @@ export {
77
87
  serverNotStarted,
78
88
  trustProxyUnset,
79
89
  unauthenticated,
90
+ webhookSignatureInvalid,
91
+ webhookSignatureStale,
80
92
  } from './errors';
81
93
  export type { ForwardedInput, ForwardedSplit } from './forwarded';
82
94
  export {
@@ -192,3 +204,9 @@ export { createServer } from './server';
192
204
  export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
193
205
  export type { InferOutput, Schema, ValidationOutcome } from './validate';
194
206
  export { formatIssue, validate, validateSync } from './validate';
207
+ export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
208
+ export {
209
+ DEFAULT_WEBHOOK_BODY_LIMIT,
210
+ DEFAULT_WEBHOOK_TOLERANCE_MS,
211
+ verifyWebhookSignature,
212
+ } from './webhook-verify';
package/src/type-pins.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // test file and a claim written there can never fail. Nothing here emits or is imported — a
4
4
  // regression is a build error, the only enforcement that counts (axiom 3).
5
5
 
6
+ import type { Ctx } from '@ultimat3/core';
6
7
  import type { HttpConfig, HttpConfigInput } from './config';
8
+ import type { RequestContext } from './context';
7
9
  import type { AuthzDecision } from './hooks';
8
10
 
9
11
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
@@ -70,3 +72,16 @@ export type _EveryHttpConfigKeyIsSettable = Assert<
70
72
  // `keyof HttpConfigInput` by construction and the assertion is vacuously true whatever anyone
71
73
  // edits — a claim that cannot fail is not a claim. The derivation IS the enforcement there; this
72
74
  // file only pins what a derivation cannot say.
75
+
76
+ /**
77
+ * `RequestContext` IS a `Ctx`, so `asCtx` stays a checked widening rather than an assertion.
78
+ *
79
+ * `asCtx` already carries this claim at its own call site and this pin is not a duplicate of it:
80
+ * `asCtx` is a function body, and a future edit answering a failure there with a cast would delete
81
+ * the enforcement and leave the comment. A pin has nothing to cast.
82
+ *
83
+ * The direction that matters is this one and not the reverse — `Ctx extends RequestContext` is
84
+ * FALSE by design, because core's `Ctx` carries no `requestHeaders`, which is precisely what
85
+ * `assertInRequest` exists to prove one way at runtime.
86
+ */
87
+ export type _RequestContextIsACtx = Assert<RequestContext extends Ctx ? true : false>;
@@ -0,0 +1,133 @@
1
+ // The inbound half of the framework's webhook mechanism: prove a request was signed by the holder
2
+ // of a shared secret, recently, over the bytes it actually carries. It is a plain function and not
3
+ // a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and
4
+ // only the route knows which one applies.
5
+ //
6
+ // THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here. That
7
+ // module is at the tier both halves can reach: `@ultimat3/jobs` (tier 3) signs a delivery and its
8
+ // boundary forbids this package, and this package (tier 2) may not reach tier 3. What stays here
9
+ // is the POLICY — what counts as fresh, how large a body may be, and which refusal a receiver
10
+ // answers with.
11
+
12
+ import type { Clock } from '@ultimat3/core';
13
+ import {
14
+ isCanonicalWebhookField,
15
+ parseWebhookSignatureHeader,
16
+ readWithinLimit,
17
+ systemClock,
18
+ timingSafeEqual,
19
+ WEBHOOK_FIELD_MAX,
20
+ WEBHOOK_ID_HEADER,
21
+ WEBHOOK_SIGNATURE_HEADER,
22
+ WEBHOOK_TOPIC_HEADER,
23
+ webhookMac,
24
+ } from '@ultimat3/core';
25
+ import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './errors';
26
+
27
+ /**
28
+ * How far a delivery's timestamp may sit from this clock, either way. Five minutes is the window
29
+ * every sender in the wild already assumes, and it is a REPLAY BOUND, not a latency allowance: a
30
+ * captured request stops being usable after it, which is the only thing that keeps an intercepted
31
+ * delivery from being replayable forever.
32
+ */
33
+ export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000;
34
+
35
+ /**
36
+ * Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this
37
+ * function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a
38
+ * receiver that must hold a 4 MB payload says so here rather than by widening every route's cap.
39
+ */
40
+ export const DEFAULT_WEBHOOK_BODY_LIMIT = 1_048_576;
41
+
42
+ export interface WebhookVerifyOptions {
43
+ /** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
44
+ readonly secret: string;
45
+ /** Defaults to `DEFAULT_WEBHOOK_TOLERANCE_MS`. */
46
+ readonly toleranceMs?: number;
47
+ /** Defaults to `DEFAULT_WEBHOOK_BODY_LIMIT`. Enforced while the body streams. */
48
+ readonly maxBytes?: number;
49
+ /** Defaults to `systemClock`. A window no test can freeze is a window no test pins. */
50
+ readonly clock?: Clock;
51
+ }
52
+
53
+ export interface VerifiedWebhook {
54
+ /**
55
+ * The sender's id for this event, signed and therefore unforgeable. It is the DEDUPE key: a
56
+ * delivery replayed inside the tolerance window verifies again by design, and this is what lets
57
+ * a receiver notice. The seen-set is the app's table — the framework has nowhere to keep one.
58
+ */
59
+ readonly eventId: string;
60
+ /** The sender's routing label. Carried and signed, never interpreted (axiom 8). */
61
+ readonly topic: string;
62
+ /**
63
+ * The exact text the signature covers. Parse THIS, never `request.json()` — the body stream is
64
+ * spent, and a re-serialisation would not be the bytes that were signed.
65
+ */
66
+ readonly body: string;
67
+ readonly signedAtMs: number;
68
+ }
69
+
70
+ /**
71
+ * Prove the request came from the holder of `secret`, inside the tolerance window, over the bytes
72
+ * it carries — and answer what was signed.
73
+ *
74
+ * The order is deliberate: the mac is checked BEFORE the window, so `X_WEBHOOK_SIGNATURE_STALE`
75
+ * means "authentic and old" and never "unreadable and old". An operator reading it goes to a clock
76
+ * or a replay, which is what that code is for.
77
+ */
78
+ export async function verifyWebhookSignature(
79
+ request: Request,
80
+ options: WebhookVerifyOptions,
81
+ ): Promise<VerifiedWebhook> {
82
+ const pathname = new URL(request.url).pathname;
83
+ const signature = parseWebhookSignatureHeader(request.headers.get(WEBHOOK_SIGNATURE_HEADER));
84
+ if (signature === undefined) {
85
+ throw webhookSignatureInvalid(
86
+ pathname,
87
+ `no readable ${WEBHOOK_SIGNATURE_HEADER} on the request`,
88
+ );
89
+ }
90
+
91
+ const eventId = request.headers.get(WEBHOOK_ID_HEADER) ?? '';
92
+ const topic = request.headers.get(WEBHOOK_TOPIC_HEADER) ?? '';
93
+ if (!isCanonicalWebhookField(eventId) || !isCanonicalWebhookField(topic)) {
94
+ throw webhookSignatureInvalid(
95
+ pathname,
96
+ `${WEBHOOK_ID_HEADER} and ${WEBHOOK_TOPIC_HEADER} must each be 1-${WEBHOOK_FIELD_MAX} characters and carry no ":"`,
97
+ );
98
+ }
99
+
100
+ const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT;
101
+ // Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that
102
+ // announces no length must not be able to make this handler hold an unbounded payload before the
103
+ // signature it was never going to pass is even computed.
104
+ const read = await readWithinLimit(request.body, maxBytes);
105
+ if ('over' in read) {
106
+ throw bodyInvalid(pathname, [`body is at least ${read.over} bytes, limit is ${maxBytes}`]);
107
+ }
108
+
109
+ // The mac is core's, over the RAW bytes: an HMAC is over a byte stream, so hashing the prefix
110
+ // and then the body is identical to hashing one string — and it never round-trips a body that is
111
+ // not valid UTF-8 through a decoder before the mac is taken over it.
112
+ const expected = webhookMac({
113
+ secret: options.secret,
114
+ timestampText: signature.timestampText,
115
+ eventId,
116
+ topic,
117
+ body: read.bytes,
118
+ });
119
+ // `timingSafeEqual`, never `===`: this is a mac comparison, and where the two first differ is
120
+ // exactly what a timing oracle needs to forge one byte at a time.
121
+ if (!timingSafeEqual(expected, signature.mac)) {
122
+ throw webhookSignatureInvalid(pathname, 'the signature does not match the body that arrived');
123
+ }
124
+
125
+ const signedAtMs = signature.timestampSeconds * 1_000;
126
+ const toleranceMs = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS;
127
+ const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs);
128
+ // Both directions: a sender whose clock runs ahead is the same replay window pointed the other
129
+ // way, and accepting the future half doubles it.
130
+ if (skewMs > toleranceMs) throw webhookSignatureStale(pathname, skewMs, toleranceMs);
131
+
132
+ return { eventId, topic, body: new TextDecoder().decode(read.bytes), signedAtMs };
133
+ }