@ultimat3/http 11.3.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -20,7 +20,28 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
20
20
  ## Rules
21
21
 
22
22
  - Route `meta.auth` is required. Never default a route to public.
23
- - **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and
23
+ - **An app declares its half of `HttpConfig` through `configureHttp()`, and the boot lays its own
24
+ facts over it** (`As of 2026-08-24`). Until 12.0.0 the entire tuning surface was **unreachable
25
+ from a shipped app**: `AppConfig` has never had an `http` key, `RuntimeOverrides` carries none,
26
+ and the only construction any shipped process made was one fixed literal in
27
+ `packages/cli/src/dev-roles.ts` passing eight boot facts — so `DEFAULT_CORS.origins` was `[]` in
28
+ every deployment (an SPA on `app.example.com` calling `api.example.com` could not work, ever),
29
+ `bodyLimitBytes` was 1 MiB for a 4 MB CSV endpoint, `requestTimeoutMs` 30s for a five-minute
30
+ export, and `rateLimit.buckets` was 120 burst / 2 rps for a bank and a blog alike. Fourteen
31
+ `fix:` lines told the reader to edit `http.<key>` in `app.config.ts`, which has never held one.
32
+ It is a registration and not a config key for `configureAuthenticator`'s reason, stated in
33
+ `hooks.ts`: `@ultimat3/core` is tier 0 and cannot hold this package's types, so an `http` block
34
+ on `AppConfig` would be a **second declaration** of `HttpConfigInput` in a package that can
35
+ never check it against this one. `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`,
36
+ **derived, never listed**: a key the boot always overwrites (`port`, `hostname`, `dev`,
37
+ `buildId`, `signInPath`, `trustProxy`, `trustedProxyHops`, `rateLimit.scope`) is a type error
38
+ where an app writes it, rather than a value silently discarded at every boot. `mergeHttpConfig`
39
+ merges one level down — `security.csp.extend` per DIRECTIVE, because the app's CDN source and
40
+ the boot's inline-script hash are each the whole answer for something, and either alone breaks a
41
+ page. `type-pins.ts` holds the other half: a key on `HttpConfig` and not on `HttpConfigInput` is
42
+ a build error, which `scripts/config-readers.ts` cannot see — that ratchet walks `AppConfig` and
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
24
45
  `asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set
25
46
  none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw
26
47
  `TypeError: ctx.now is not a function` on every audited action served over HTTP,
@@ -30,6 +51,46 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
30
51
  and it is a type-pin rather than a `.test.ts` because `tsconfig.json` excludes tests.
31
52
  `ctx.buildId` is core's meaning — the build this PROCESS serves; the CLIENT's claim is
32
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.
33
94
  - **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
34
95
  resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
35
96
  parsed by a stage arrived one frame after the span's context was already frozen: the caller's
@@ -60,6 +121,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
60
121
  with `AbortSignal.any`, which is what `context.ts` had documented and nothing wired — a closed
61
122
  tab held its handler, its pool slot and its vendor connection for the whole 30s. `expired` stays
62
123
  the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
124
+ **And it leaves this process on the next hop's headers, `As of 2026-08-24`**:
125
+ `Deadline.deadlineAt` is published as core's `ctx.deadlineAt`, and `traceHeaders()` (tier 0, the
126
+ one thing both typed clients spread before the caller's own headers) sends what is LEFT as
127
+ `x-request-timeout-ms`. Before that the header had exactly one reader — `resolveTimeoutMs`, in
128
+ this file — and **zero writers anywhere in the tree**, so gateway → A (30s) → B meant a call made
129
+ at t=29 started B on a FRESH 30s: real work, holding a pool slot and a vendor connection, half a
130
+ minute after A's socket was answered `X_TIMEOUT`. A spent budget sends no header at all rather
131
+ than `0`, because `resolveTimeoutMs` ignores anything under 1ms and falls back to its own.
63
132
  With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared
64
133
  never-aborted singleton, which every such request used to share — one `abort` listener per
65
134
  request, accumulating for the life of the process. Always `deadline.clear()` in the `finally` —
@@ -95,6 +164,38 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
95
164
  `error-map` stage is the one call site that can see the config, and every degraded `problem()` in
96
165
  the tail must stay opaque. The real text is not lost — it is the log field and the error report,
97
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.
98
199
  - **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
99
200
  `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
100
201
  `error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
@@ -242,6 +343,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
242
343
  `lastResort` spells its one `type` as a literal because that function calls nothing, and
243
344
  `pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
244
345
  cannot drift. Never assert either value as a copied string — import the constant.
346
+ - **`error-map.ts` answers the status; `error-facts.ts` renders the throwable** (`As of
347
+ 2026-08-24`). One file did both and reached 501 lines, over the ceiling. The seam between
348
+ them is `declaredStatusFor(code)` — `number | undefined`, exported to this package only —
349
+ because the two questions are genuinely different: `statusFor` always answers a number, while
350
+ "did ANYBODY classify this code" is what decides whether a 5xx may carry the throwable's own
351
+ words back to the caller (`isUnclassifiedFailure`). Imports go one way, `error-facts.ts` →
352
+ `error-map.ts`; a status read from the facts file would be the second table this package
353
+ spent a release deleting.
245
354
  - Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
246
355
  table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
247
356
  `registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
@@ -309,8 +418,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
309
418
  `X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
310
419
  the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
311
420
  not one.
421
+ - **One request spends a LIST of rate-limit keys, and the tenant's is the second** (`As of
422
+ 2026-08-24`). `rateLimitKey` picked ONE subject — actor > org > ip, exclusive — and `actorView`
423
+ answers `null` for anonymous, so `orgId` was consulted only for a caller with an org and no id:
424
+ **no authenticated request ever touched an org bucket**. A tenant with 8,000 seats whose
425
+ integration entered a retry loop therefore took 8,000 × the per-actor burst against one shared
426
+ pool, every bucket inside its own limit, and no number an operator could set would have refused
427
+ it — while `@ultimat3/jobs` has had `perTenant` since it shipped. `rateLimitSpends` answers the
428
+ caller's key **and** `tenant|org:<id>` when the app declared `rateLimit.tenantBucket`; the stage
429
+ spends them in order and stops at the first refusal, so a caller its own bucket already refused
430
+ costs its tenant nothing. The tenant key is deliberately NOT scoped to the route — a per-route
431
+ tenant bucket is the same number multiplied by the route table, which is not a cap. `null` is
432
+ the default because one tenant is a person and the next is five thousand seats (axiom 8), and a
433
+ name nothing declares is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, never a
434
+ silent fall-through to `default`. The headers report the bucket **closest to refusing**: telling
435
+ a client `remaining: 99` off its own bucket while its tenant's holds 2 is a number that plans a
436
+ caller into a 429.
312
437
  - **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
313
- The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
438
+ The key falls back to the connection address (`rateLimitSpends`), so a scan rotating through an
314
439
  IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
315
440
  entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
316
441
  missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
@@ -378,6 +503,33 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
378
503
  is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
379
504
  caller who built their own may have meant their own numbers. A limiter that declares no table
380
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.**
381
533
  - Never throw a bare `Error` — use a factory from `errors.ts`.
382
534
  - No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
383
535
  - Health endpoints answer outside the pipeline, on purpose.
@@ -402,12 +554,13 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
402
554
  | `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
403
555
  | `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
404
556
  | `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
405
- | `error-map.ts` | code → status table + `factsOf()` |
557
+ | `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
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 |
406
559
  | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
407
560
  | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
408
561
  | `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
409
562
  | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
410
- | `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 |
411
564
  | `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
412
565
  | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
413
566
  | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
@@ -419,8 +572,10 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
419
572
  | `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
420
573
  | `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
421
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 |
422
576
  | `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
423
577
  | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
578
+ | `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
424
579
 
425
580
  ## Commands
426
581
 
package/README.md CHANGED
@@ -13,8 +13,10 @@ to skip.
13
13
  | the ordered request lifecycle | `pipeline.ts` |
14
14
  | typed request (params, query, body) | `request.ts` |
15
15
  | response constructors + `problem()` | `response.ts` |
16
- | code → status, `factsOf()` | `error-map.ts` |
16
+ | code → status, closed table | `error-map.ts` |
17
+ | `factsOf()`, the problem document, the terminal lines | `error-facts.ts` |
17
18
  | token-bucket limiting, `toBucket` | `rate-limit.ts` |
19
+ | the app's own HTTP declaration, and the boot's facts over it | `app-config.ts` |
18
20
  | CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
19
21
  | CSRF (origin proof for a credentialed write) | `csrf.ts` |
20
22
  | the request deadline and `ctx.signal` | `deadline.ts` |
@@ -22,6 +24,37 @@ to skip.
22
24
  | the inbound request id and trace, read before the span | `correlation.ts` |
23
25
  | dev error overlay | `overlay.ts` |
24
26
 
27
+ ## What an app declares: `configureHttp()`
28
+
29
+ ```ts
30
+ // apps/web/app/http.ts — module scope, imported by the app like any other module
31
+ import { configureHttp } from '@ultimat3/http';
32
+
33
+ configureHttp({
34
+ cors: { origins: ['https://app.example.com'], credentials: true },
35
+ bodyLimitBytes: 8 * 1024 * 1024, // this API takes a 4 MB CSV
36
+ requestTimeoutMs: 300_000, // and an export that really does take five minutes
37
+ rateLimit: {
38
+ tenantBucket: 'tenant',
39
+ buckets: { tenant: { capacity: 5_000, refillPerSecond: 100 } },
40
+ },
41
+ });
42
+ ```
43
+
44
+ One registration, read once by whatever process starts the web role — the same seam
45
+ `configureAuthenticator()` is. **Breaking, `As of 2026-08-24`**: before it, the only `HttpConfig`
46
+ any shipped process built was a fixed literal inside `@ultimat3/cli`, so none of the four values
47
+ above could be set from an app at all — `cors.origins` was `[]` in every deployment, which refuses
48
+ every cross-origin browser call, permanently. `AppConfig` has never carried an `http` key and does
49
+ not gain one: `@ultimat3/core` is tier 0 and cannot hold this package's types.
50
+
51
+ `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>` — `port`, `hostname`, `dev`,
52
+ `buildId`, `signInPath`, `trustProxy`, `trustedProxyHops` and `rateLimit.scope` are the boot's, and
53
+ writing one here is a **type error** rather than a value silently overwritten at the next boot.
54
+ `mergeHttpConfig(configuredHttp(), boot)` is the layering, and it merges `security.csp.extend` per
55
+ directive: the app's CDN source and the boot's inline-script hash are each the whole answer for
56
+ something.
57
+
25
58
  ## The pipeline is the guarantee
26
59
 
27
60
  ```
@@ -57,6 +90,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
57
90
  | HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
58
91
  | `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `createServer`, because N replicas each holding their own counters enforce N × every configured number |
59
92
  | a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `createServer`, because the loser would be a number someone read and nothing applied |
93
+ | a `rateLimit.tenantBucket` naming a bucket nothing declares | `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, because the name would fall through to `default` and a whole tenant's cap would silently be the 120-burst read bucket |
60
94
  | an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
61
95
  | a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |
62
96
  | `trustProxy: true` with no `trustedProxyHops` | `X_TRUST_PROXY_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `trustProxy` now defaults to `false`, and `x-forwarded-for` is read at `entries.length - hops` — never at `[0]`, which is whatever the client typed |
@@ -79,6 +113,16 @@ thing that knows where its counters live, and a framework that inferred the answ
79
113
  environment would get it wrong on the first deployment that scaled differently.
80
114
 
81
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
+
82
126
  createServer({
83
127
  routes,
84
128
  config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
@@ -102,6 +146,18 @@ so nothing can be wrong.
102
146
  `rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
103
147
  maths stays in `createRateLimiter`, so every driver agrees on the numbers.
104
148
 
149
+ **One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
150
+ else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
151
+ builder used to pick exactly ONE subject and consult `orgId` only when there was no actor id,
152
+ which no authenticated request ever satisfies: a tenant with 8,000 seats took 8,000 × the
153
+ per-actor burst against one shared pool, every bucket inside its own limit, and no number an
154
+ operator could set would have refused it. The tenant key is `tenant|org:<id>` and is deliberately
155
+ NOT scoped to the route — a per-route tenant bucket is the same allowance once per route. The
156
+ spend stops at the first refusal, so a caller its own bucket refused costs its tenant nothing, and
157
+ the `ratelimit-*` headers report the bucket closest to refusing. `tenantBucket` defaults to `null`:
158
+ one tenant is a person and the next is five thousand seats, so there is no allowance a framework
159
+ can pick for you.
160
+
105
161
  **A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table
106
162
  and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,
107
163
  `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
@@ -188,6 +244,8 @@ flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** — a ro
188
244
  cannot forget to declare its auth posture.
189
245
 
190
246
  ```ts
247
+ import { createServer, defineHttpConfig, json } from '@ultimat3/http';
248
+
191
249
  const handle = createServer({
192
250
  routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
193
251
  handler: (req) => json({ id: req.param('id') }) }],
@@ -199,11 +257,56 @@ const handle = createServer({
199
257
  Static paths are registered in Bun's native `routes` table; param/wildcard paths fall
200
258
  through to `fetch`. Method resolution stays ours so a 405 still carries problem+json.
201
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
+
202
304
  ## Errors
203
305
 
204
306
  `X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
205
307
  · `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
206
- · `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`
207
310
 
208
311
  One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
209
312
  overlay — so the `code`/`cause`/`fix` strings can never diverge.
@@ -217,7 +320,7 @@ in a table row and a table row has no anchor. Assert against `problemTypeFor` an
217
320
 
218
321
  ## Boundaries
219
322
 
220
- Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
221
- policy evaluation arrive through `ServerHooks`, declared structurally, because
222
- `@ultimat3/policy` is a sibling tier. There is no plugin API: `Middleware` wraps a
223
- handler, the pipeline is everything else.
323
+ Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time`
324
+ tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
325
+ `ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
326
+ plugin API: `Middleware` wraps a handler, the pipeline is everything else.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "11.3.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": "11.3.0",
35
- "@ultimat3/i18n": "11.3.0",
36
- "@ultimat3/schema": "11.3.0",
37
- "@ultimat3/time": "11.3.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
  }
@@ -0,0 +1,146 @@
1
+ // The app's own HTTP declaration, and the layering that keeps a boot fact above it. One
2
+ // registration site, read once by whatever process starts the web role — the same seam
3
+ // `configureAuthenticator()` is, and for the same reason: `@ultimat3/core` is tier 0 and cannot
4
+ // hold this package's types, so an `http` block on `AppConfig` would be a second declaration of
5
+ // `HttpConfigInput` in a package that can never check it against this one.
6
+
7
+ import type { HttpConfigInput } from './config';
8
+ import type { RateLimitConfig } from './rate-limit';
9
+
10
+ /**
11
+ * The keys the BOOT owns, and the reason this type is an `Omit` rather than a hand-written list of
12
+ * what an app may say. Each of these is a fact about the PROCESS — the port it was told to bind,
13
+ * the build it serves, whether it is `x dev`, how many proxies the deployment puts in front of it,
14
+ * `auth.signInPath` from `app.config.ts` — so a value an app wrote for one of them would be
15
+ * overwritten at every boot: a switch with no wire, which is the defect this whole surface exists
16
+ * to remove. Refused at the type level, which is the build error that enforces it.
17
+ */
18
+ export type BootOwnedHttpKey =
19
+ | 'port'
20
+ | 'hostname'
21
+ | 'dev'
22
+ | 'buildId'
23
+ | 'signInPath'
24
+ | 'trustProxy'
25
+ | 'trustedProxyHops';
26
+
27
+ /**
28
+ * What an app declares. `rateLimit.scope` is boot-owned for the same reason as the keys above:
29
+ * `startWeb` DERIVES it from the store it installed, so a literal here would be a second
30
+ * declaration quietly contradicting the object beside it — and `assertRateLimitScope` compares
31
+ * exactly those two halves.
32
+ */
33
+ export type AppHttpConfig = Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'> & {
34
+ readonly rateLimit?: Omit<Partial<RateLimitConfig>, 'scope'> | undefined;
35
+ };
36
+
37
+ /**
38
+ * The app's declaration, if it made one. A single value and not a list, exactly as
39
+ * `configuredAuthenticator` is: two answers to "how does this server bind and what does it admit"
40
+ * is two configurations, and the one that ran first wins.
41
+ *
42
+ * Process-global for the reason that one is: the app has exactly one boot, and every host that
43
+ * starts a server (`x dev`, `apps/web/server.ts`) would otherwise need its own way to be handed
44
+ * the same values — which is what left the whole tuning surface unreachable, since the only
45
+ * shipped construction was a fixed literal inside the CLI.
46
+ */
47
+ let declared: AppHttpConfig | undefined;
48
+
49
+ export const configureHttp = (config: AppHttpConfig): void => {
50
+ declared = config;
51
+ };
52
+
53
+ /** What the boot layers its own facts over. `undefined` means the locked defaults stand. */
54
+ export const configuredHttp = (): AppHttpConfig | undefined => declared;
55
+
56
+ /** Test seam. Production configures once at module scope and never unconfigures. */
57
+ export const resetHttpConfig = (): void => {
58
+ declared = undefined;
59
+ };
60
+
61
+ type SecurityInput = NonNullable<HttpConfigInput['security']>;
62
+ type CspInput = NonNullable<SecurityInput['csp']>;
63
+ type CspExtend = NonNullable<CspInput['extend']>;
64
+
65
+ /**
66
+ * Per directive, both lists. The app's `script-src` is a CDN it serves scripts from and the boot's
67
+ * is the sha256 of the hydration runtime this process emits inline — each is the whole answer for
68
+ * something, so a merge that let either win breaks a page: the CDN script, or every island.
69
+ *
70
+ * Built through a `Map`, never by assigning `out[directive]`: a directive named `__proto__` sets
71
+ * the PROTOTYPE rather than a key, which is a source silently dropped from the one header this
72
+ * package locks down hardest.
73
+ */
74
+ const mergeCspExtend = (
75
+ app: CspExtend | undefined,
76
+ boot: CspExtend | undefined,
77
+ ): CspExtend | undefined => {
78
+ if (app === undefined) return boot;
79
+ if (boot === undefined) return app;
80
+ const merged = new Map<string, readonly string[]>(Object.entries(app));
81
+ for (const [directive, sources] of Object.entries(boot)) {
82
+ merged.set(directive, [...(merged.get(directive) ?? []), ...sources]);
83
+ }
84
+ return Object.fromEntries(merged);
85
+ };
86
+
87
+ const mergeSecurity = (
88
+ app: SecurityInput | undefined,
89
+ boot: SecurityInput | undefined,
90
+ ): SecurityInput | undefined => {
91
+ if (app === undefined) return boot;
92
+ if (boot === undefined) return app;
93
+ const extend = mergeCspExtend(app.csp?.extend, boot.csp?.extend);
94
+ const csp: CspInput = {
95
+ ...app.csp,
96
+ ...boot.csp,
97
+ ...(extend === undefined ? {} : { extend }),
98
+ };
99
+ return { ...app, ...boot, csp };
100
+ };
101
+
102
+ /**
103
+ * The app's declaration with the boot's own facts laid OVER it — the one order that can be right.
104
+ * `buildId`, the port, the CSP hashes of what this process emits and the scope of the store it
105
+ * installed are all things the boot measured; an app can only have guessed at them. Everything
106
+ * else the app said survives, which is the whole point of it having said anything.
107
+ *
108
+ * Sections merge one level down rather than being replaced whole: `security: { csp: { extend } }`
109
+ * from the boot would otherwise delete an app's `hsts`, `frameAncestors` and its own extends, and
110
+ * `rateLimit: { scope }` would delete every bucket it declared.
111
+ */
112
+ export const mergeHttpConfig = (
113
+ app: AppHttpConfig | undefined,
114
+ boot: HttpConfigInput,
115
+ ): HttpConfigInput => {
116
+ if (app === undefined) return boot;
117
+ // `rateLimit` is lifted out of the spread rather than overwritten by it: `AppHttpConfig` types
118
+ // it WITHOUT `scope` and `HttpConfigInput` types it with, so under `exactOptionalPropertyTypes`
119
+ // the spread of the narrower optional is not assignable to the wider one. The merged value is
120
+ // computed below and put back.
121
+ const { rateLimit: appRateLimit, ...appRest } = app;
122
+ const cors =
123
+ app.cors === undefined && boot.cors === undefined ? undefined : { ...app.cors, ...boot.cors };
124
+ const csrf =
125
+ app.csrf === undefined && boot.csrf === undefined ? undefined : { ...app.csrf, ...boot.csrf };
126
+ const locale =
127
+ app.locale === undefined && boot.locale === undefined
128
+ ? undefined
129
+ : { ...app.locale, ...boot.locale };
130
+ const tz = app.tz === undefined && boot.tz === undefined ? undefined : { ...app.tz, ...boot.tz };
131
+ const rateLimit: Partial<RateLimitConfig> | undefined =
132
+ appRateLimit === undefined && boot.rateLimit === undefined
133
+ ? undefined
134
+ : { ...appRateLimit, ...boot.rateLimit };
135
+ const security = mergeSecurity(app.security, boot.security);
136
+ return {
137
+ ...appRest,
138
+ ...boot,
139
+ ...(cors === undefined ? {} : { cors }),
140
+ ...(csrf === undefined ? {} : { csrf }),
141
+ ...(locale === undefined ? {} : { locale }),
142
+ ...(tz === undefined ? {} : { tz }),
143
+ ...(rateLimit === undefined ? {} : { rateLimit }),
144
+ ...(security === undefined ? {} : { security }),
145
+ };
146
+ };
package/src/config.ts CHANGED
@@ -1,5 +1,8 @@
1
- // The HTTP slice of `app.config.ts`. One resolver, so a value is either a locked
2
- // default or an explicit override — never "whatever the first caller passed".
1
+ // The resolver every HTTP config goes through, so a value is either a locked default or an
2
+ // explicit override — never "whatever the first caller passed". It is NOT a slice of
3
+ // `app.config.ts`, which this file claimed for four majors while `AppConfig` has never carried an
4
+ // `http` key: an app declares its half through `configureHttp()` (`app-config.ts`) and the boot
5
+ // lays its own facts over it before calling this.
3
6
  import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
4
7
  import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
5
8
  import { type CsrfConfig, DEFAULT_CSRF } from './csrf';