@ultimat3/http 30.9.0 → 31.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
@@ -23,8 +23,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
23
23
  from `HttpConfigInput`.
24
24
  - **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role); the deleted
25
25
  key and `ServerOptions.drain`: `docs/history/http.md#no-drain-deadline`.
26
- - **`cors.origins: ['*']` with `credentials: true`, or `'null'` in the list, is `X_CORS_CONFIG_INVALID`.** A refused origin
27
- still gets `vary: origin`.
26
+ - **`X_CORS_CONFIG_INVALID`: `cors.origins: ['*']` with `credentials: true`, `'null'`, or any entry
27
+ but `'*'` whose `URL.origin` is not itself** (fix names the canonical form). A refused origin still gets `vary: origin`.
28
28
  - **`DEFAULT_CORS` is what `clientTransport` sends and reads**, off core's constants; `cors.test.ts` observes the transport's real traffic, so a new header fails it. `date` alone stays hidden: this server writes delta-seconds. An app's list replaces the default whole.
29
29
  - **A `security.csp.extend` entry must emit only the directive it names**
30
30
  (`X_CSP_DIRECTIVE_INVALID`); `buildCsp` builds through a `Map`.
@@ -58,7 +58,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
58
58
  - **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A route may
59
59
  REPLACE it with `meta.authenticate` (never run beside it): `bearerMount` sets it so a session
60
60
  cookie authenticates nothing on `/v1/*`.
61
- - **`bearerMount` re-serves existing `Route`s, never re-projects them** (`bearer-mount.ts`): the cut is decided in the `auth` stage, outside the scopes is `X_ROUTE_NOT_FOUND`, the prefix is opaque. The token allowance is spent once, in the authenticator, for every resolved token in scope or not (a 422 or 403 before the handler pays). A declared `rateLimit` REPLACES `default` on the mount's routes (`rateLimitedBy: 'handler'`); a route's own `meta.rateLimit` and the tenant allowance stay. A successor `Link` is rewritten in a `finally` to the mounted path when in the cut under a scope the token holds, else removed (`bearer-mount-wire.test.ts`). Full rule: `docs/history/http.md`.
61
+ - **`bearerMount` re-serves existing `Route`s, never re-projects them** (`bearer-mount.ts`): the cut is decided in the `auth` stage (outside it: `X_ROUTE_NOT_FOUND`); the token allowance is spent once, in the authenticator; a declared `rateLimit` REPLACES `default`, a route's own and the tenant allowance stay; a successor `Link` is rewritten or removed (`bearer-mount-wire.test.ts`). Full rule: `docs/history/http.md`.
62
62
  - **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
63
63
  - **A dev 4xx asks `hooks.errorPage` first; a 5xx is always the overlay** (#492): the app's file
64
64
  when it has one, else the overlay. Production asks for every status (`stages-error-page.test.ts`).
@@ -70,9 +70,11 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
70
70
  `finalize.ts` owns the tail. Imports go `pipeline.ts` → `stages.ts`; a stage reads
71
71
  `StageRunnersInput`, never `PipelineDeps`. A new stage is an entry in both `PIPELINE_STAGES` and the
72
72
  `Record<StageName, StageRun>` table, with a `why` and a test.
73
- - **The request span is named by the route PATTERN** (`GET /r/:token`, `routeSpanName`), started
74
- as the bare method and renamed after the match; `http.route` is the pattern or `unmatched`. A
75
- concrete URL carries tokens and is attacker-chosen — never a span name, attribute or label.
73
+ - **The request span is named by the route PATTERN** (`GET /r/:token`, `routeSpanName`), renamed
74
+ on match; `http.route` is the pattern or `unmatched`. A concrete URL (tokens,
75
+ attacker-chosen) is never a span name, attribute or label — no `url.path`. Keys: core's
76
+ `SEMCONV`; the id is `ultimate.request_id`. **`http.request` is emitted once, in the same
77
+ `finally`**, after `deadline.clear()` (`pipeline-telemetry.test.ts`).
76
78
  - **The CSP `connect-src` is `'self' blob:`** — no bare `ws:`/`wss:`. A cross-origin sync node is
77
79
  the boot's `csp.extend` of that origin exactly. `security.hsts` merges key by key.
78
80
  - **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
@@ -94,6 +96,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
94
96
  `hooks.authorize`; `'handler'` stands the `authz` stage down.
95
97
  - **A 403's `fix:` names the POLICY** (`route.meta.policy`), degrading to `x routes --json` for a
96
98
  composite.
99
+ - **`maxRequestBodyBytes` (128 MiB, ≥ `bodyLimitBytes`) is the transport ceiling**; Bun's is off.
100
+ A declared length past it (`admit`) or a streamed one (`server.ts`) is `X_BODY_TOO_LARGE` (413).
97
101
  - **The body cap is enforced while reading** (`UltimateRequest.#read`'s counting reader, cancelling
98
102
  past `bodyLimitBytes`); multipart goes through the same capped bytes. **Bytes with no
99
103
  `content-type` are `X_BODY_INVALID`** from `bodyRaw()`, never "no body"; `bodyBytes()` reads them.
@@ -131,7 +135,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
131
135
  `markReady()`, `drain()`, the payloads) — never a private state or in-flight counter.
132
136
  - **`stop()` hands its two hooks back ABOVE its early return** (`packages/http/e2e/server.e2e.test.ts`
133
137
  reads `shutdownHookCount()`).
134
- - **`compress` is the last stage and has no option** (`compress.ts`, its counted read `compress-read.ts`). A signed-in answer is coded only when `immutable` (`compressResponse(..., { anonymous })`, declared, never defaulted). The ceiling is 256 KiB, measured. A response that fails the rule is the SAME object with its body unread. `cache-control` with `private`, `no-store`, `no-cache` or `no-transform` is identity (BREACH); `vary: accept-encoding` goes on every answer past the type and cache conditions, coded or not. `content-length` is the bound: `html`, `textResponse`, `jsonResponse` declare it, `stream` never. A read that overruns, fails or passes `ctx.signal`, and a `gzipSync` throw, log `http.compress_failed` and answer identity, whole: never a request failure.
138
+ - **`compress` is the last stage and has no option** (`compress.ts`, `compress-read.ts`). A signed-in answer is coded only when `immutable` (`{ anonymous }`, declared, never defaulted). 1–256 KiB declared is `gzipSync`ed; past 256 KiB, or undeclared once 1 KiB is read, `CompressionStream` with no length. An undeclared NDJSON feed never is. A refused response is the SAME object. `private`, `no-store`, `no-cache` or `no-transform` is identity (BREACH); `vary: accept-encoding` goes on every answer past the type and cache conditions. A counted read that overruns, fails or passes `ctx.signal`, or a `gzipSync` throw, logs `http.compress_failed`: identity, whole.
135
139
 
136
140
  ## Rules — errors and the problem document
137
141
 
@@ -146,16 +150,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
146
150
  `registerProblemMeta({ CODE: { publicCause: true } })`). `toProblem(error, { dev })` defaults `dev`
147
151
  to false. An unclassified 5xx carries nothing off the throwable. **A hidden cause hides the `fix`
148
152
  too**: `callerFix`, else `x errors explain <CODE> --json` — never `facts.fix` outside dev.
149
- - **The document carries the ISSUE LIST** as a top-level `issues` member: `issuesOf` is total,
150
- all-or-nothing, absent (never `[]`) when none, dropped past `MAX_PROBLEM_ISSUES` (100), dropped under
151
- the opacity condition; `received` forced to `''`, entries rebuilt member by member.
153
+ - **The document carries the ISSUE LIST** (`issues`): `issuesOf` is total, all-or-nothing,
154
+ absent when none, past 100 or opaque; `received` is `''`. `issuesTruncated: true` rides only
155
+ with a carried list and `meta.issuesTruncated === true`. Both are reserved meta keys.
152
156
  - **`meta` reaches the document only by declaration** (`registerProblemMeta({ CODE: [keys] })`, app
153
157
  codes only; not `issues`, not `__proto__`): `wireMeta` copies declared keys through
154
158
  `Object.defineProperty`, bounded by `MAX_PROBLEM_META_BYTES`, dropped when opaque.
155
159
  - **A rejected value is a log FIELD, never the message** — the `error-map` line's message is the code alone.
156
160
  - **A rejected BODY names only what the framework chose**: `bodyInvalid`'s `issues` are a fixed
157
161
  vocabulary; what the caller sent rides in `meta`; the parser's message goes through `renderThrowable`.
158
- - **A browser failing `auth: 'required'` is redirected; an agent gets the problem document** (`auth-redirect.ts`, in `error-map` before the overlay). Full rule: `docs/history/http.md`.
162
+ - **A browser failing `auth: 'required'` is redirected; an agent gets the problem document** (`auth-redirect.ts`, in `error-map` before the overlay), in the routed locale. Full rule: `docs/history/http.md`.
159
163
  - **A pathname in a `fix:` goes through `renderFixShellArg`** (`routeNotFound`). **A `code` is gated by
160
164
  core's `FRAMEWORK_CODE`** before `x errors explain <code>`, else `x errors list --json`. **A supplied
161
165
  `fix:` is taken only from a branded error** (`isUltimateError`).
@@ -167,24 +171,20 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
167
171
 
168
172
  ## Rules — rate limiting
169
173
 
170
- - **One request spends a LIST of keys**: the caller's and, when `rateLimit.tenantBucket` is declared,
171
- `tenant|org:<id>` (not route-scoped); stop at the first refusal; an undeclared name is
172
- `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN`. Headers report the bucket closest to refusing.
173
- - **The memory store is bounded** (`forgetAtMs` sweep, `DEFAULT_MAX_RATE_LIMIT_KEYS`, evicting the
174
- entries closest to FULL first — never LRU).
175
- - **The limiter takes a `Clock`** (`rateLimiter({ clock })`); `deps.limiter` is the one seam.
176
- - **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
177
- `X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `httpPipeline`) refuses `'shared'` over a
178
- per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `httpServer({ rateLimitStore })`.
179
- - **A failed `auth: 'required'` is metered in the `auth` stage**. Full rule: `docs/history/http.md`.
180
- - **`postgresRateLimitStore({ executor })` is the shared store**, over `@ultimat3/core`'s structural
181
- `PgExecutor` (re-exported here). The
182
- refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
183
- `purgeExpired(nowMs)` takes the caller's clock.
184
- - **A bucket a route names must be registered**: `withRouteBuckets` merges `meta.rateLimitBucket`
185
- into the table in `httpServer` and `httpPipeline`; any disagreement is
186
- `X_RATE_LIMIT_BUCKET_CONFLICT`. **The installed limiter must hold it too** — `RateLimiter.buckets`
187
- is declared and `assertRouteBuckets` refuses, never rebinds.
174
+ Full rules and their reasons: [`docs/history/http-rate-limit.md`](../../docs/history/http-rate-limit.md).
175
+
176
+ - **An address key is core's `addressNetwork(ip)`** (IPv6 `/64`). **One request spends a LIST of
177
+ keys** (caller's, then `tenant|org:<id>` when declared); stop at the first refusal.
178
+ - **The memory store is bounded** (`DEFAULT_MAX_RATE_LIMIT_KEYS`, evicts closest-to-FULL first).
179
+ - **The scope is DECLARED, with no default** (`X_RATE_LIMIT_SCOPE_UNSET`); `'shared'` over a
180
+ per-process store is `X_RATE_LIMIT_NOT_SHARED`, checked in `httpPipeline`.
181
+ - **ONE store slot** (`rate-limit-installed.ts`): routes AND primitives count in
182
+ `installedRateLimitStore()`; a server reads it PER REQUEST (`liveRateLimitStore`). Installer and
183
+ reset are on `@ultimat3/http/adapter` only (`adapter.test.ts`); an app names `rateLimitStore` in
184
+ `apps/<app>/runtime.ts`. `httpServer` takes no store (v31).
185
+ - **A bucket a route names must be registered** (`withRouteBuckets`, `X_RATE_LIMIT_BUCKET_CONFLICT`)
186
+ and held by the limiter (`assertRouteBuckets`, `X_RATE_LIMIT_BUCKET_UNBOUND`).
187
+ - Shared stores: `postgresRateLimitStore({ executor })`, `redisRateLimitStore({ client })`.
188
188
 
189
189
  ## Rules — webhooks and tests
190
190
 
@@ -197,7 +197,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
197
197
  - **`verifyWebhookSignature` and `verifyBodyHmac` share ONE secret screen** (`assertWebhookSecrets`)
198
198
  and one pair of refusals (`webhookSecretMissing`, `webhookSignatureInvalid`), each naming its caller.
199
199
  - Tests must not touch the network — the preload seals `fetch`. Socket tests live in `e2e/` (`bun test
200
- packages/http/e2e`), sealed; `start()` calls core's `markListening()`. Never unseal.
200
+ ./packages/http/e2e/`), sealed; `start()` calls core's `markListening()`. Never unseal.
201
201
 
202
202
  ## Files
203
203
 
@@ -206,12 +206,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
206
206
  | `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
207
207
  | `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
208
208
  | `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
209
- | `router.ts` | trie, static > param > wildcard, `path-invalid` for a segment that will not decode. A request segment is decoded ONCE; a static child is looked up raw, then decoded. A wildcard takes an EMPTY rest after the node's own routes (`/docs` → `/docs/*path`, `path: ''`). Segments, decoding and `canonicalPath` are `route-path.ts`'s: `ctx.routePath` is the match's one spelling, `requestAddress(ctx)` what render keys `isr` on |
210
- | `navigation.ts` | the client router's server half: the gate `context` runs after the match (a prefetch to a route whose `meta.navigation` did not opt in → `204`; a soft GET to anything but a page of the router's `<app>:<surface>` → `204` + `x-ultimate-location`, before auth or app code) and the `response` stage's rewrite of a 3xx to a router request into the same hand-over, cookies kept |
209
+ | `router.ts` | trie, static > param > wildcard; a segment decoded ONCE (static looked up raw, then decoded), `path-invalid` if it will not. A wildcard takes an EMPTY rest (`/docs` → `/docs/*path`). `route-path.ts` owns segments and `canonicalPath`: `ctx.routePath` is the match's one spelling, `requestAddress(ctx)` what render keys `isr` on |
210
+ | `navigation.ts` | the client router's server half: `context`'s gate after the match (an un-opted prefetch, or a soft GET off the router's `<app>:<surface>` → `204` [+ `x-ultimate-location`], before auth) and `response`'s rewrite of a 3xx to that hand-over, cookies kept |
211
211
  | `error-map.ts` | the code → status table, closed: COMPOSES the slices and holds no row of its own |
212
- | `error-map-http.ts`, `error-map-tier-<0-4>.ts` | the rows, one slice per owning tier (this package's own apart). `bun run new-error-code` appends to the slice its `--package` maps to; a code in two slices is refused there and by `error-map.test.ts` |
212
+ | `error-map-http.ts`, `error-map-tier-<0-4>.ts` | the rows, one slice per owning tier. `bun run new-error-code` appends to its `--package`'s slice; a code in two is refused by `error-map.test.ts` |
213
213
  | `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 |
214
- | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices`, `errorPage`, `explainMiss` (asked ONLY on a `not-found` match, synchronously, with the request line alone; its error replaces `X_ROUTE_NOT_FOUND`) + the app's `configureAuthenticator()` |
214
+ | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices`, `errorPage`, `explainMiss` (asked ONLY on a `not-found` match, synchronously, with the request line alone; its error replaces `X_ROUTE_NOT_FOUND`) + the app's `configureAuthenticator(fn, { credentialTtlMs })` |
215
215
  | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
216
216
  | `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
217
217
  | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
@@ -222,12 +222,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
222
222
  | `stored-object-headers.ts` | inline only for raster images, audio, video; else `attachment` + `sandbox` (private) |
223
223
  | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
224
224
  | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
225
- | `error-log-level.ts` | the level of the `error-map` stage's one line: `error` for 5xx, `warn` for 401/403/429, `info` for the rest |
225
+ | `error-log-level.ts` | the `error-map` line's level: `error` 5xx, `warn` 401/403/429, `info` the rest and a `routineRefusal` (`unauthenticated()`) |
226
226
  | `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `httpRouter`; the response path's `finiteDeltaSeconds` is the total half of the same rule |
227
- | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
227
+ | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver |
228
+ | `adapter.ts` | `@ultimat3/http/adapter`: the store port's interface, the shipped stores' types (also on `.`, beside their factories), the driver error (adapter only), and the boot's installer |
229
+ | `rate-limit-config.ts` | the bucket table, its resolver and `toBucket` |
228
230
  | `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
229
- | `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling |
230
- | `error-titles.ts` | the owned codes, their titles and the two `retry-after` classes, registered at import — the one module `sideEffects` lists, imported bare by the barrel and `errors.ts`. Server realm only: a browser reads an http refusal's title off the body and its delay off `Retry-After` (core's decoders), so no http module rides into an island |
231
+ | `rate-limit-redis.ts` | the other shared store: one Lua token bucket per take |
232
+ | `body-ceiling.ts` | the transport ceiling and `X_BODY_TOO_LARGE` |
233
+ | `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the declaration faults |
234
+ | `error-titles.ts` | the owned codes, titles and `retry-after` classes, registered at import — the one `sideEffects` module. Server realm only: a browser reads title and delay off the body and `Retry-After` |
231
235
  | `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
232
236
  | `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
233
237
  | `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
@@ -238,7 +242,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
238
242
  | `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only. It negotiates nothing; the cookie is read by core's `readCookie`, imported, never re-exported |
239
243
  | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
240
244
  | `rate-limit-stage.ts` | what the pipeline spends and when: the `rate-limit` stage's list of keys, and the address-keyed allowance the `auth` stage spends for a failed credential |
241
- | `health-disclosure.ts` | WHO the web role's `/healthz` and `/readyz` tell the detail to: the `healthDetailPeers` config key, its screen, and the trusted-proxy half. The body rule and the peer match are core's (`health-disclosure.ts` there), shared with the sync node |
245
+ | `health-disclosure.ts` | WHO `/healthz` and `/readyz` tell the detail to: `healthDetailPeers`, its screen, the trusted-proxy half. Body rule and peer match are core's |
242
246
  | `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
243
247
  | `request-facts.ts` | `RequestFacts`, any token resolver's frozen 2nd arg; address = `ctx.ip` |
244
248
  | `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
@@ -249,7 +253,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
249
253
  ## Commands
250
254
 
251
255
  ```
252
- bun test packages/http
256
+ bun test ./packages/http/
253
257
  bun run --filter @ultimat3/http typecheck
254
258
  ```
255
259
 
package/README.md CHANGED
@@ -31,6 +31,7 @@ configureHttp({ bodyLimitBytes: 8 * 1024 * 1024, requestTimeoutMs: 300_000 });
31
31
  | the request deadline and `ctx.signal` | `deadline.ts` |
32
32
  | the caller's real address behind a proxy | `forwarded.ts` |
33
33
  | the inbound request id and trace, read before the span | `correlation.ts` |
34
+ | the `server` span (OpenTelemetry's stable `http.*`/`url.*`/`server.*` names, `ultimate.request_id`) and `http.request` on core's instrumentation bus, once per request | `pipeline.ts` |
34
35
  | dev error overlay | `overlay.ts` |
35
36
 
36
37
  ## What an app declares: `configureHttp()`
@@ -51,7 +52,10 @@ configureHttp({
51
52
  ```
52
53
 
53
54
  One registration, read once by whatever process starts the web role — the same seam
54
- `configureAuthenticator()` is. **Breaking, `As of 2026-08-24`**: before it, the only `HttpConfig`
55
+ `configureAuthenticator(authenticate, { credentialTtlMs? })` is. `credentialTtlMs` is what a
56
+ resolved identity is worth on a connection that carries no further request (the `sync` node's
57
+ websocket re-asks after it; omitted, five minutes) — the credential's lifetime is the
58
+ authenticator's fact, so it is declared beside the function, never as a second authenticator. **Breaking, `As of 2026-08-24`**: before it, the only `HttpConfig`
55
59
  any shipped process built was a fixed literal inside `@ultimat3/cli`, so none of the four values
56
60
  above could be set from an app at all — `cors.origins` was `[]` in every deployment, which refuses
57
61
  every cross-origin browser call, permanently. `AppConfig` has never carried an `http` key and does
@@ -101,7 +105,11 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
101
105
  | a repeated form field | a LIST, exactly as a repeated query parameter is. One collector for query, urlencoded and multipart; `Object.fromEntries` kept the last value, so a checkbox group reached the schema as one string |
102
106
  | a cross-origin request from an origin the allow-list refuses | no `access-control-allow-origin`, but always `vary: origin`, so a shared cache never answers an allowed origin out of the refusal's slot |
103
107
  | `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
108
+ | a `cors.origins` entry no browser sends — `'https://app.com/'`, `'https://App.com'`, `'https://app.com:443'`, `'app.com'` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, its `fix` naming the canonical form (`URL.origin`). `As of 2026-11` (v31): the match is byte for byte, so each spelling matched nothing and refused every cross-origin write |
109
+ | a body past `maxRequestBodyBytes` (default 128 MiB, never below `bodyLimitBytes`) — declared or streamed | `X_BODY_TOO_LARGE` (413). `As of 2026-11`: Bun's own `maxRequestBodySize` is off — it answered an empty, uncoded 413 before any framework code, and its 128 MiB default refused every larger signed upload. `configureHttp({ maxRequestBodyBytes })` raises the ceiling |
104
110
  | `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test`, and any other control character. `nextAfterSignIn` takes the value its caller's parser already decoded and never decodes it again, `As of 2026-10`: `?q=a%26b` lands as `?q=a%26b`, not `?q=a&b`. A code point above U+007F is percent-encoded so a `Location` header can carry it. The answer is the normalised path (`/a/../b` → `/b`), and one whose pathname starts `//` (`/.//evil.test`) is refused; `x-ultimate-location` never carries a same-origin pathname starting `//` either |
111
+ | a browser with no session on a localized URL (`/es/account`) | `303` to the sign-in page in that locale (`/es/login?next=%2Fes%2Faccount`), `As of 2026-11` — it went to the default-locale `signInPath` and switched the visitor's language at the door |
112
+ | `Range: bytes=5-3` (last byte before the first) | ignored, `200` with the whole body (RFC 9110: an invalid range-spec is ignored), `As of 2026-11`; `416` stays for a valid range past the size |
105
113
  | HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
106
114
  | `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `httpServer`, because N replicas each holding their own counters enforce N × every configured number |
107
115
  | a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `httpServer`, because the loser would be a number someone read and nothing applied |
@@ -129,7 +137,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
129
137
  | `buildId: null` | skew detection off, whatever `BUILD_ID` says; `hostname` never defaults from `HOSTNAME` (the container id under Docker) |
130
138
  | an `x-forwarded-client-cert` value in quotes | unescaped ONCE, after the pairs are split — `Subject="O=Acme; Inc,CN=svc-one"` is that whole subject, not `O=Acme` |
131
139
  | a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
132
- | a public text answer, 1 KiB to 256 KiB, with a declared length | gzip on the wire, from the last stage (`compress`), `vary: accept-encoding` and a weak `etag`. A private answer, a signed-in one that is not `immutable`, `no-transform`, a range or an undeclared length stays identity and unread. HEAD carries GET's coded headers. `html`, `textResponse`, `jsonResponse` declare their bytes; `stream` never does. Exported: `isCompressibleType(contentType)`, `COMPRESS_MIN_BYTES`, `COMPRESS_MAX_BYTES` |
140
+ | a public text answer of at least 1 KiB, or with no declared length | gzip on the wire, from the last stage (`compress`), `vary: accept-encoding` and a weak `etag`. Up to 256 KiB with a declared length it is coded whole (`gzipSync`, with its `content-length`); past that, or a stream with no declared length once its first 1 KiB has arrived — CSV, a large JSON export — it is coded as the client pulls (`CompressionStream`, no `content-length`), `As of 2026-11`; an undeclared body that ends under 1 KiB leaves identity with its length, and an undeclared NDJSON feed is never coded (the coder emits nothing until its source closes). A HEAD answered with no body has nothing to measure and stays identity. A private answer, a signed-in one that is not `immutable`, `no-store`, `no-transform` or a range stays identity and unread. HEAD carries GET's coded headers. Exported: `isCompressibleType(contentType)`, `COMPRESS_MIN_BYTES`, `COMPRESS_MAX_BYTES` |
133
141
 
134
142
  `handle()` resolves to a Response, always — a stage that throws after the handler, or while
135
143
  rendering another stage's throw, degrades to `X_PIPELINE_FINALIZE_FAILED` (500, the stage named in
@@ -148,22 +156,19 @@ reads (refill applied, **no write and no row** — a key never taken is full; `r
148
156
  `refilledTokens` do the arithmetic) and `reset(key)`. `peek` is required, `As of 2026-10`: the
149
157
  `auth` stage asks it on every request to an `auth: 'required'` route.
150
158
 
159
+ **One store, one place to choose it, `As of 31.0.0`.** Routes and primitives (an action's or a
160
+ query's declared `rateLimit:`) count in ONE installed store. The boot installs
161
+ `postgresRateLimitStore` over the pool it opened; an app that wants another names it in
162
+ `apps/<app>/runtime.ts`, and nowhere else — `httpServer({ rateLimitStore })` and the barrel's
163
+ `installRateLimitStore` / `adoptRateLimitStore` are gone (Upgrading, 30 → 31). The installer lives
164
+ on `@ultimat3/http/adapter`, which the boot imports; an app never calls it.
165
+
151
166
  ```ts
152
- import {
153
- httpServer,
154
- defineHttpConfig,
155
- type RateLimitStore,
156
- type Route,
157
- } from '@ultimat3/http';
158
-
159
- declare const routes: readonly Route[];
160
- declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
161
-
162
- httpServer({
163
- routes,
164
- config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
165
- rateLimitStore: myStore, // whose own scope is 'shared'
166
- });
167
+ // apps/web/runtime.ts
168
+ import type { RateLimitStore } from '@ultimat3/http';
169
+
170
+ declare const myStore: RateLimitStore; // its own scope is 'shared'
171
+ export const runtime = { rateLimitStore: myStore };
167
172
  ```
168
173
 
169
174
  | Declared | Store | Result |
@@ -179,8 +184,8 @@ said one replica" the same value while `docker/helm/values.yaml` runs three —
179
184
  the one nobody declared. A limiter with `enabled: false` owes no declaration: nothing is enforced,
180
185
  so nothing can be wrong.
181
186
 
182
- `rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
183
- maths stays in `rateLimiter`, so every driver agrees on the numbers.
187
+ The installed store feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the
188
+ bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
184
189
 
185
190
  **One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
186
191
  else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
@@ -201,28 +206,37 @@ and one `insert … on conflict` per take (a peek is one `select`), so N replica
201
206
  line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`.
202
207
 
203
208
  ```ts
209
+ // apps/web/runtime.ts — only when the boot's own default is not the one you want
204
210
  import { baseClient, dbExecutor } from '@ultimat3/db';
205
- import {
206
- httpServer,
207
- defineHttpConfig,
208
- type PgExecutor,
209
- postgresRateLimitStore,
210
- type Route,
211
- } from '@ultimat3/http';
212
-
213
- declare const routes: readonly Route[];
211
+ import { type PgExecutor, postgresRateLimitStore } from '@ultimat3/http';
214
212
 
215
213
  // The pool this process already opened, never a request's open transaction — the boot builds
216
214
  // the same executor when it installs the store for you.
217
215
  const executor: PgExecutor = dbExecutor(baseClient);
218
216
 
219
- httpServer({
220
- routes,
221
- config: defineHttpConfig({ rateLimit: { scope: 'shared' } }),
222
- rateLimitStore: postgresRateLimitStore({ executor }),
223
- });
217
+ export const runtime = { rateLimitStore: postgresRateLimitStore({ executor }) };
224
218
  ```
225
219
 
220
+ **A Redis store ships too, `As of 2026-11`** — `redisRateLimitStore({ client })`, one Lua token
221
+ bucket per take (`EVALSHA`, falling back to `EVAL` on `NOSCRIPT`), a peek one `HMGET`. A hot key —
222
+ one NAT, a tenant bucket — no longer serialises every request on one Postgres row lock. Each key
223
+ expires once it would be full again, so it needs no purge. Same `scope: 'shared'`, same deadline
224
+ (`deadlineMs`, 250 ms) and the same degrade-to-this-pod's-memory on an outage, `ERR_REDIS_CONNECTION_CLOSED`
225
+ included. Connect the client before the first request, or the first takes are decided in memory.
226
+
227
+ ```ts
228
+ // apps/web/runtime.ts
229
+ import { redisClient } from '@ultimat3/core/redis';
230
+ import { redisRateLimitStore } from '@ultimat3/http';
231
+
232
+ export const runtime = { rateLimitStore: redisRateLimitStore({ client: redisClient(), prefix: 'app' }) };
233
+ ```
234
+
235
+ **An IPv6 caller is its `/64`, `As of 2026-11` (v31)** — `@ultimat3/core`'s `addressNetwork(ip)` is the address
236
+ every anonymous key and the failed-credential key count by: IPv4 whole (an IPv4-mapped IPv6 is its
237
+ IPv4), any other IPv6 `<first four hextets>::/64`. One subscriber holds a /64, so the full address
238
+ handed each holder a fresh bucket per request.
239
+
226
240
  The table bounds itself only when something asks it to: `store.purgeExpired(ctx.now().getTime())`
227
241
  from a `task` drops every bucket that has refilled to capacity, which is the memory store's forget
228
242
  rule. `nowMs` is required and must come from the same clock the takes use — measured against the
@@ -265,8 +279,7 @@ the same rule as `RateLimitStore.scope` — and `assertRouteBuckets` compares it
265
279
  at construction. A limiter passed to `PipelineDeps.limiter` that cannot enforce a declared bucket
266
280
  is refused rather than rebound: a `RateLimiter` is opaque, so rebinding would mean discarding the
267
281
  store it carries, and a caller who built their own limiter may have meant their own numbers. Pass
268
- the **store** — `httpServer({ routes, rateLimitStore })` — and the pipeline builds the limiter
269
- from the merged table for you.
282
+ no limiter — `httpServer({ routes })` builds one over the installed store from the merged table.
270
283
 
271
284
  ## Routing
272
285
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "30.9.0",
3
+ "version": "31.0.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -27,7 +27,8 @@
27
27
  "provenance": true
28
28
  },
29
29
  "exports": {
30
- ".": "./src/index.ts"
30
+ ".": "./src/index.ts",
31
+ "./adapter": "./src/adapter.ts"
31
32
  },
32
33
  "files": [
33
34
  "src",
@@ -45,9 +46,9 @@
45
46
  "test": "bun test"
46
47
  },
47
48
  "dependencies": {
48
- "@ultimat3/core": "30.9.0",
49
- "@ultimat3/i18n": "30.9.0",
50
- "@ultimat3/schema": "30.9.0",
51
- "@ultimat3/time": "30.9.0"
49
+ "@ultimat3/core": "31.0.0",
50
+ "@ultimat3/i18n": "31.0.0",
51
+ "@ultimat3/schema": "31.0.0",
52
+ "@ultimat3/time": "31.0.0"
52
53
  }
53
54
  }
package/src/adapter.ts ADDED
@@ -0,0 +1,14 @@
1
+ // `@ultimat3/http/adapter` — the rate-limit store port: the interface a driver implements, the
2
+ // errors it throws, and the boot's installer. The installer lives here and never on the `.` barrel
3
+ // so an app has exactly one way to choose a store: `rateLimitStore` in `apps/<app>/runtime.ts`.
4
+
5
+ export type { Bucket, RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
6
+ export { rateLimitStoreUnavailable } from './rate-limit-errors';
7
+ export {
8
+ clearDefaultRateLimitBuckets,
9
+ installRateLimitStore,
10
+ resetRateLimitStore,
11
+ withScratchRateLimitStore,
12
+ } from './rate-limit-installed';
13
+ export type { RateLimitPeek } from './rate-limit-peek';
14
+ export { RATE_LIMIT_STORE_DEADLINE_MS } from './rate-limit-store-deadline';
@@ -2,6 +2,7 @@
2
2
  // client want `X_UNAUTHENTICATED` as JSON with a fix line; a person following a link wants the
3
3
  // sign-in page. One condition, two audiences, decided here so the error stage stays one branch.
4
4
 
5
+ import { localizedPath } from '@ultimat3/i18n';
5
6
  import type { RequestContext } from './context';
6
7
  import { acceptsHtml } from './html-render';
7
8
  import type { RedirectIntent } from './response';
@@ -19,12 +20,17 @@ export const NEXT_PARAM = 'next';
19
20
  *
20
21
  * 303, not 302: the request that failed authz may have been a form POST, and 303 turns the
21
22
  * follow-up into the GET the sign-in page actually is. Same reasoning as `setRedirect`.
23
+ *
24
+ * The sign-in page is spelled in the locale the URL routed (`ctx.pathLocale`): `/es/account` goes
25
+ * to `/es/login`, never the default-locale `/login`, which switched a visitor's language at the door.
22
26
  */
23
27
  export function signInRedirect(input: {
24
28
  readonly code: string;
25
29
  readonly signInPath: string | null;
26
30
  readonly request: Request;
27
- readonly ctx: Pick<RequestContext, 'url' | 'method'>;
31
+ readonly ctx: Pick<RequestContext, 'url' | 'method'> & {
32
+ readonly pathLocale?: RequestContext['pathLocale'];
33
+ };
28
34
  }): RedirectIntent | undefined {
29
35
  const { code, signInPath, request, ctx } = input;
30
36
  if (code !== 'X_UNAUTHENTICATED' || signInPath === null) return undefined;
@@ -33,9 +39,11 @@ export function signInRedirect(input: {
33
39
  if (!acceptsHtml(request)) return undefined;
34
40
  // A sign-in page that declares `auth: 'required'` by mistake would otherwise redirect to
35
41
  // itself forever, and a browser reports that as a bare "too many redirects" with no code.
36
- if (ctx.url.pathname === signInPath) return undefined;
42
+ const signIn =
43
+ ctx.pathLocale === undefined ? signInPath : localizedPath(signInPath, ctx.pathLocale);
44
+ if (ctx.url.pathname === signInPath || ctx.url.pathname === signIn) return undefined;
37
45
  const next = `${ctx.url.pathname}${ctx.url.search}`;
38
- return { location: `${signInPath}?${NEXT_PARAM}=${encodeURIComponent(next)}`, status: 303 };
46
+ return { location: `${signIn}?${NEXT_PARAM}=${encodeURIComponent(next)}`, status: 303 };
39
47
  }
40
48
 
41
49
  /**
@@ -11,15 +11,16 @@
11
11
  import type { Actor, Clock } from '@ultimat3/core';
12
12
  import {
13
13
  ACTION_PATH_PREFIX,
14
+ liveClock,
14
15
  QUERY_PATH_PREFIX,
15
16
  successorLink,
16
17
  successorPathOf,
17
- systemClock,
18
18
  } from '@ultimat3/core';
19
19
  import type { RequestContext } from './context';
20
20
  import { bearerMountInvalid, routeNotFound } from './errors';
21
21
  import type { RateLimitDecision, RateLimitStore } from './rate-limit';
22
- import { memoryRateLimitStore, toBucket } from './rate-limit';
22
+ import { memoryRateLimitStore } from './rate-limit';
23
+ import { toBucket } from './rate-limit-config';
23
24
  import { rateLimited } from './rate-limit-errors';
24
25
  import { rateLimitHeaders } from './rate-limit-headers';
25
26
  import type { UltimateRequest } from './request';
@@ -156,7 +157,8 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
156
157
  const bucket =
157
158
  input.rateLimit === undefined ? undefined : toBucket(`bearer mount ${prefix}`, input.rateLimit);
158
159
  const store = input.rateLimitStore ?? memoryRateLimitStore();
159
- const clock = input.clock ?? systemClock;
160
+ // Read per request, never captured at mount: an unpinned mount follows the `clock` port.
161
+ const nowMs = (): number => (input.clock ?? liveClock).now().getTime();
160
162
  // The caller each request resolved to, keyed by ITS context — never a module-level slot two
161
163
  // concurrent requests could share.
162
164
  const callers = new WeakMap<RequestContext, { caller: BearerCaller }>();
@@ -201,9 +203,9 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
201
203
  `${prefix}|token:${key}`,
202
204
  bucket,
203
205
  1,
204
- clock.now().getTime(),
206
+ nowMs(),
205
207
  );
206
- for (const [name, value] of Object.entries(rateLimitHeaders(decision, clock.now().getTime()))) {
208
+ for (const [name, value] of Object.entries(rateLimitHeaders(decision, nowMs()))) {
207
209
  ctx.headers.set(name, value);
208
210
  }
209
211
  // `rateLimited` carries the seconds in `meta`, which the error-map stage turns into
@@ -0,0 +1,54 @@
1
+ // The transport ceiling: the most bytes this process reads in one request, for any route. Bun's
2
+ // own `maxRequestBodySize` answered an empty, uncoded 413 before framework code ran, so the
3
+ // framework holds the limit instead — refused by declared length in `admit`, and counted on a
4
+ // streamed body — and Bun's is lifted out of the way.
5
+
6
+ import { HttpError } from './errors';
7
+
8
+ /** Bun's former default, so an app that declares nothing reads exactly what it read before. */
9
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 134_217_728;
10
+
11
+ /** What `Bun.serve` is given: no ceiling of its own. The framework's is `maxRequestBodyBytes`. */
12
+ export const BUN_MAX_REQUEST_BODY_SIZE = Number.MAX_SAFE_INTEGER;
13
+
14
+ export const requestBodyTooLarge = (pathname: string, bytes: number, ceiling: number): HttpError =>
15
+ new HttpError({
16
+ code: 'X_BODY_TOO_LARGE',
17
+ cause: `${pathname} was sent at least ${bytes} bytes and the most this process reads in one request is ${ceiling} (http.maxRequestBodyBytes)`,
18
+ fix: `configureHttp({ maxRequestBodyBytes: ${bytes} }) # in the app's http.ts — or send it in parts: storage's signPart/completeUpload for an upload`,
19
+ });
20
+
21
+ /** The declared `content-length`, refused when it is already over the ceiling. */
22
+ export const assertDeclaredBody = (request: Request, pathname: string, ceiling: number): void => {
23
+ const header = request.headers.get('content-length');
24
+ if (header === null) return;
25
+ const declared = Number.parseInt(header, 10);
26
+ if (Number.isFinite(declared) && declared > ceiling) {
27
+ throw requestBodyTooLarge(pathname, declared, ceiling);
28
+ }
29
+ };
30
+
31
+ /**
32
+ * A body with no declared length, counted: past `ceiling` the stream errors with
33
+ * `X_BODY_TOO_LARGE`, so whoever reads it — the framework's capped reader or a handler's raw
34
+ * `arrayBuffer()` — never holds more. A declared length needs nothing: HTTP framing ends the body
35
+ * there, and `admit` already refused it if it was over.
36
+ */
37
+ export const capStreamedBody = (request: Request, ceiling: number): Request => {
38
+ if (request.body === null || request.headers.has('content-length')) return request;
39
+ let total = 0;
40
+ const pathname = new URL(request.url).pathname;
41
+ const counted = request.body.pipeThrough(
42
+ new TransformStream<Uint8Array, Uint8Array>({
43
+ transform(chunk, controller) {
44
+ total += chunk.byteLength;
45
+ if (total > ceiling) {
46
+ controller.error(requestBodyTooLarge(pathname, total, ceiling));
47
+ return;
48
+ }
49
+ controller.enqueue(chunk);
50
+ },
51
+ }),
52
+ );
53
+ return new Request(request, { body: counted, duplex: 'half' } as RequestInit);
54
+ };
package/src/byte-range.ts CHANGED
@@ -15,7 +15,8 @@ export const RANGE_UNSATISFIABLE = 'unsatisfiable';
15
15
  /**
16
16
  * One range or none. A multi-range or malformed header is ignored rather than refused — RFC 9110
17
17
  * lets a server answer the whole representation, and `multipart/byteranges` is a body format no
18
- * caller of these routes asks for. `bytes=-` names no byte and is ignored the same way.
18
+ * caller of these routes asks for. `bytes=-` names no byte and is ignored the same way, and so is
19
+ * `bytes=5-3`, whose last byte comes before its first.
19
20
  */
20
21
  export function parseByteRange(
21
22
  header: string | null | undefined,
@@ -37,8 +38,11 @@ export function parseByteRange(
37
38
  : { start: Math.max(size - wanted, 0), end: size - 1 };
38
39
  }
39
40
  const start = Number(from);
41
+ // RFC 9110 §14.1.1: a last-pos below its first-pos is an INVALID range-spec, ignored like any
42
+ // other malformed header — 416 is for a valid range this size cannot meet.
43
+ if (to !== '' && Number(to) < start) return undefined;
40
44
  const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
41
- return start >= size || end < start ? RANGE_UNSATISFIABLE : { start, end };
45
+ return start >= size ? RANGE_UNSATISFIABLE : { start, end };
42
46
  }
43
47
 
44
48
  /**