@ultimat3/http 1.1.0 → 2.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 ADDED
@@ -0,0 +1,285 @@
1
+ # @ultimat3/http
2
+
3
+ Owned request lifecycle over `Bun.serve`. Tier 2.
4
+
5
+ ## Boundary
6
+
7
+ - May import: `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`, `@ultimat3/time` — tiers 0
8
+ and 1, which is the whole rule. There is no extra restriction here; the line used to read
9
+ "core, schema. Nothing else, ever", stated no reason, and was stricter than the tier table.
10
+ **What it bought was three re-implementations.** `locale.ts` carried its own `negotiateLocale`,
11
+ `isValidTimeZone` and `resolveTimeZone`, and each disagreed with its owner about the same
12
+ request: an app shipping `{ en, fr }` resolved `ctx.locale` to `'en'` forever, a switcher writing
13
+ the documented `LOCALE_COOKIE` (`x_locale`) was read by nothing because this package spelled it
14
+ `x-locale`, and `x-timezone: +01:00` — a fixed offset with no DST rules — became `ctx.tz` and
15
+ threw four packages later. Adding a tier-1 import is cheaper than a fourth divergence.
16
+ - May NOT import `@ultimat3/policy` or `@ultimat3/entity` — same tier. Authz and auth
17
+ come in via `ServerHooks` (`hooks.ts`), declared structurally.
18
+ - `@ultimat3/action` (tier 3) is what wires policy into `hooks.authorize`.
19
+
20
+ ## Rules
21
+
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
24
+ `asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set
25
+ none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw
26
+ `TypeError: ctx.now is not a function` on every audited action served over HTTP,
27
+ `useService()` threw a `TypeError` instead of the `X_SERVICE_MISSING` it exists to raise, and
28
+ `throwIfAborted()` — the documented cancellation seam — was inert on the one surface where a
29
+ caller can actually go away. Never reintroduce the assertion: the type error IS the enforcement,
30
+ and it is a type-pin rather than a `.test.ts` because `tsconfig.json` excludes tests.
31
+ `ctx.buildId` is core's meaning — the build this PROCESS serves; the CLIENT's claim is
32
+ `ctx.clientBuildId`, read only by `assertBuild()`.
33
+ - **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
34
+ resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
35
+ parsed by a stage arrived one frame after the span's context was already frozen: the caller's
36
+ trace was discarded, the root span carried a dashed UUIDv7 no collector accepts as a trace id,
37
+ and the log lines beside it quoted a third value. The `request-id` and `trace` stages now only
38
+ PUBLISH what was decided — they do not decide. The regex is core's `parseTraceparent`, one copy.
39
+ - **Every proxy-supplied header goes through `forwardedElement(header, hops)` and nothing else.**
40
+ `trustProxy` documented reading `x-forwarded-for` and had no reader at all, so behind any ingress
41
+ every anonymous request keyed to the proxy — one `auth` bucket (capacity 10) for the whole
42
+ internet, and one scanner enough to 429 every signup on the fleet. The entry read is
43
+ `entries.length - hops`, never `[0]`, which is whatever the client typed; a chain shorter than
44
+ declared trusts nothing rather than falling back leftward. `trustProxy` defaults to **false** and
45
+ requires `trustedProxyHops` (`X_TRUST_PROXY_UNSET` at `defineHttpConfig`) — it also gates the
46
+ `x-request-id` echo, and a direct caller choosing its own request id poisons log correlation.
47
+ `x-forwarded-proto` rides the same rule, which is what finally emits HSTS behind a
48
+ TLS-terminating ingress, and so does Envoy's `x-forwarded-client-cert` (`peer-identity.ts`).
49
+ **A peer certificate read from an untrusted hop is worse than none, because it authenticates** —
50
+ so `ctx.peer` is `null` for an untrusted deployment, a missing header and a short chain alike.
51
+ `ctx.peer` is never an actor: `hooks.authenticate` is the one funnel, through
52
+ `verifyWorkloadToken()` -> `actorFromService()` in `@ultimat3/auth`.
53
+ - **One deadline per request, and it is what makes `ctx.signal` exist.** `deadline.ts` holds the
54
+ `AbortController` and the timer; `config.requestTimeoutMs` (30s, `0` disables) is the budget and
55
+ a caller may SHORTEN it with `x-request-timeout-ms`, never lengthen it. Two halves, both needed:
56
+ the abort is what cooperative code unwinds on, and the race in `execute` is what answers the
57
+ socket when a handler never looked at the signal. `X_TIMEOUT` is borrowed (core's concept) and
58
+ already mapped to 504. Always `deadline.clear()` in the `finally` — a live timer keeps the event
59
+ loop from going idle, so a process that answered everything still refuses to exit.
60
+ - **`admit` is the second stage, and it refuses before ANY work.** `isDraining()` had no reader in
61
+ this package while this file claimed the layer answered 503 on it; past `config.maxInflight`
62
+ (1000, `0` disables) a request is shed `X_OVERLOADED` with `retry-after`. Both set the header on
63
+ `ctx.headers`, which the `response` stage merges, rather than teaching `error-map` a second
64
+ special case. The in-flight number is core's `inflightCount()` — the same counter `beginWork()`
65
+ in `server.ts` maintains — never a private one, for the same reason the drain phase is core's.
66
+ A refusal that costs as much as a served request is not load shedding.
67
+ - **`csrf` sits after `auth` and before `body`, and CORS cannot replace it.**
68
+ `application/x-www-form-urlencoded` is a CORS-*simple* content type, so a cross-site
69
+ `<form method="post">` is SENT and EXECUTED with the session cookie attached and
70
+ `cors.origins: []` only withholds the reply — long after the refund went through. After auth
71
+ because only an AMBIENT credential can be forged into (anonymous and bearer callers are exempt);
72
+ before body so a rejected write never allocates its payload. `sec-fetch-site: same-origin`, an
73
+ `Origin` equal to this app, or an `Origin` already in `cors.origins`; anything else is
74
+ `X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem). The self
75
+ origin is built from `ctx.https`, not `url.protocol`, or every legitimate post behind a
76
+ TLS-terminating ingress would be refused. **`mode: 'token'` is deliberately NOT shipped** — a
77
+ double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
78
+ token mode is worse than an honest `'origin' | 'off'`.
79
+ - **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
80
+ `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
81
+ `error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
82
+ and not reported and therefore kept for the full retention. The message is the CODE alone. The
83
+ other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
84
+ load-bearing one; this half is what makes the value redactable at all.
85
+ - **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
86
+ One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
87
+ stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
88
+ framework that guessed `/signin` would send an app spelling it `/login` to a 404 — strictly
89
+ worse than the JSON. The round trip is `?next=`, and `nextAfterSignIn` is the ONE reader of it:
90
+ anything that is not a same-origin path falls back, or the page that hands out a session
91
+ becomes an open redirect. **A control character is an off-site destination**: a browser deletes
92
+ TAB, CR and LF from a `Location` before parsing it, so `/%09/evil.test` decodes to a value that
93
+ starts with one slash, passes a prefix check and is then followed as `//evil.test`. The prefix
94
+ checks are not the last word — the value is re-parsed against an origin no relative path can
95
+ reach, and anything that resolves off it falls back. Nothing here throws either: `?next=%` is a
96
+ bare `URIError`, and this runs while the pipeline is already rendering a 401.
97
+ - **The body cap is enforced while reading, never after.** `UltimateRequest.#read` pulls the body
98
+ through a counting reader and cancels the stream the moment the running total passes
99
+ `bodyLimitBytes`. `content-length` is a courtesy — a `transfer-encoding: chunked` request
100
+ declares none, so `arrayBuffer()` allocated a 10GB payload in full before measuring it. Multipart
101
+ goes through the same capped bytes (re-parsed by `Response.formData()` off the announced
102
+ boundary) rather than being handed to the runtime as an unbounded stream, which is what left it
103
+ with no byte guard at all when the length was undeclared.
104
+ - **The cache default reads the ACTOR, not just the route, and `vary` is added and never set.**
105
+ `meta.auth` is only `'public' | 'required'`, so the page that greets a signed-in visitor by name
106
+ is a `'public'` route: keying the default off the route alone put that visitor's personalised
107
+ HTML in a shared cache for 60 seconds. A request whose actor is not anonymous is `private`;
108
+ an anonymous one stays shared-cacheable and carries `vary: accept-language, cookie`. Both halves
109
+ are required — either alone leaves the hole. `addVary` (`response.ts`) is how the `response`
110
+ stage merges CORS's `vary: origin` into the cache stage's key instead of replacing it.
111
+ - **`cors.origins: ['*']` with `credentials: true` is refused at `defineHttpConfig`.** No browser
112
+ accepts that pair, and `allowedOrigin` answering `null` for it meant the natural "open it up"
113
+ edit emitted no CORS headers at all, silently, on every request — with `DEFAULT_CORS.credentials`
114
+ (true) as the half nobody thinks to look at. `X_CORS_CONFIG_INVALID`, at config time, with the
115
+ one-line edit in the `fix`. A REFUSED origin still gets `vary: origin`: without it a shared cache
116
+ files the un-CORS'd body under the URL alone and hands it to an allowed origin next.
117
+ - **HSTS is emitted only when https is affirmed.** `securityHeaders(config, { https })` defaults to
118
+ NOT sending it — the pipeline is the one caller that knows, and it passes `ctx.https`. The guard
119
+ read `!== false`, so every other caller sent a two-year `includeSubDomains` for a connection
120
+ nothing had established was secure, which is the opposite of what the comment above it promised.
121
+ - **`meta.enforcedBy` says who evaluates `meta.policy`, and the `authz` stage obeys it.**
122
+ `'pipeline'` (the default, and what a page wants) means the stage decides through
123
+ `hooks.authorize`; `'handler'` means the handler is the one evaluation and the stage returns
124
+ without deciding — no hook required, and none consulted. An action route says `'handler'`
125
+ because `@ultimat3/action`'s `invoke` loads the row a row-level rule reads and this stage
126
+ cannot. Deciding in both places is two authz systems, and the one that answers first is the
127
+ one holding less.
128
+ - **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`,
129
+ and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The
130
+ `authenticate` hook still says it with `null`; the `auth` stage is where that becomes
131
+ `anonymousActor()`. A null here reaches every `ctx.actor` reader in the framework as a contract
132
+ violation that only shows up on the first unauthenticated request.
133
+ - **The lifecycle is three files, and the split is by responsibility, not by length.** `pipeline.ts`
134
+ owns the ORDER (`PIPELINE_STAGES`, the phases, the run loop, ALS, the span and the one metrics
135
+ call); `stages.ts` owns what each stage does and declares the vocabulary (`StageName`,
136
+ `StageRun`, `Stage`) beside the implementations it names; `finalize.ts` owns the promise that the
137
+ tail answers rather than rejects. Imports go one way — `pipeline.ts` → `stages.ts` — because a
138
+ stage body reads `StageRunnersInput`, an explicit list of what a stage may depend on, and never
139
+ `PipelineDeps`. Adding a stage means an entry in **both** `PIPELINE_STAGES` and the
140
+ `Record<StageName, StageRun>` table; the record type is what makes forgetting one a build error.
141
+ - Never add a stage to `PIPELINE_STAGES` without a `why` and a test.
142
+ - **The `locale` stage decides WHERE, the owners decide WHAT.** It reads a header and a cookie and
143
+ hands the raw strings to `@ultimat3/i18n`'s `resolveLocale` and `@ultimat3/time`'s
144
+ `resolveTimeZone`; it must never negotiate, validate or canonicalize one itself. The two answers
145
+ land on `ctx.locale` and `ctx.tz` — **core's own declared fields, the framework's only ambient
146
+ store for either** — so `currentLocale()` and `currentTimeZone()` answer for this request once
147
+ `pipeline.ts` publishes the context into the ALS. `@ultimat3/time` kept a second store
148
+ (`ctx['timeZone']`) with zero writers until 1.3.0, and the whole cost was silent: every
149
+ `@ultimat3/ui` server render formatted its dates in UTC however the request arrived. A default
150
+ for either value is `configureTime({ defaultZone })` / `defineCatalogs({ default })`, never a
151
+ third copy in `HttpConfig`.
152
+ - **`toBucket` lives here, not in `@ultimat3/action`.** `action` and `query` are the same tier and
153
+ can never import each other, so the only conversion between `{ limit, windowMs }` and a `Bucket`
154
+ sitting in one of them is why a `query` could not declare a rate limit at all. It is beside
155
+ `Bucket` and the maths it validates, and it throws http's own `X_RATE_LIMIT_INVALID`.
156
+ - Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
157
+ table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
158
+ `registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
159
+ every app code was 500 and `pipeline.ts` paged the on-call for a wrong password.
160
+ - **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
161
+ once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
162
+ `UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
163
+ second body reader past the size cap, the content-type parse and the cache.
164
+ - **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A single value,
165
+ not a list — two answers to "who is this?" is two identities per request. `@ultimat3/auth` is
166
+ the same tier and can never import this package, so the app is what wires them together.
167
+ - **`hooks.devNotices` is dev-only, and the overlay path is the only place it is called.**
168
+ `OverlayNotice` is declared structurally in `overlay.ts` because the packages that produce one
169
+ — `@ultimat3/entity`'s N+1 codes, reported by `x dev` — are this tier or above and can never be
170
+ imported here, exactly as `AuthzDecision` is. The call sits INSIDE the
171
+ `config.dev && wantsOverlay` branch: the overlay is a notice's only surface, so a production
172
+ process, or an agent that asked for problem+json, must not pay a diagnostic's per-request cost
173
+ for findings nothing renders. No notices means no card, byte for byte.
174
+ - **`matchRoute` never throws — a pathname is whatever the client typed.** `decodeURIComponent`
175
+ is called only through `router.ts`'s guarded `decodeSegment`, and a segment that will not decode
176
+ answers `{ reason: 'path-invalid', segment }` → `X_PATH_INVALID` → 400. A bare `URIError` here
177
+ reached `factsOf` as `X_INTERNAL`, so a `%ZZ` answered 500 and paged the on-call for a typo.
178
+ Only the branch that would have decoded fails: static segments are compared raw, so a path that
179
+ reaches no param or wildcard is still a 404 and precedence is unchanged.
180
+ - **`handle()` resolves to a Response or the server has no answer at all.** The request phases are
181
+ guarded by `execute`'s own `try`; the two that run after them are guarded in `finalize.ts`, and
182
+ neither guard is optional. A finalize stage that refuses the response it was handed degrades to
183
+ `X_PIPELINE_FINALIZE_FAILED` (500), and the chain runs a **second** pass over that problem
184
+ document — whose headers are writable — so the request id, CORS and the security headers still
185
+ reach the client. Two passes, never a loop. A throw inside the recover stage (an app's `onError`,
186
+ a `devNotices` producer) is answered with the problem document for the error the request actually
187
+ hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
188
+ *through* the recover stage, never around it — reporting, logging and the overlay each keep one
189
+ call site.
190
+ - **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
191
+ The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
192
+ IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
193
+ entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
194
+ missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
195
+ and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
196
+ reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
197
+ comparator for insertion order or an LRU — recency is not the same as worthlessness here.
198
+ - **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
199
+ boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
200
+ `resolveRateLimitConfig` refuses `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` when a limiter
201
+ that is ENABLED has not been told. `'process'` used to be the default, which made "nobody asked"
202
+ and "the app said one replica" the same value while `docker/helm/values.yaml` runs three — so
203
+ `assertRateLimitScope` below, which only fires on a `'shared'` declaration, could never see the
204
+ silent case. A disabled limiter owes no declaration: nothing is enforced, so nothing can be
205
+ wrong. The rest of the check is unchanged: `RateLimitStore.scope` says what a driver provides; `config.rateLimit.scope` says what
206
+ the deployment requires; `assertRateLimitScope` compares them once, inside `createPipeline` —
207
+ the one construction path `createServer`, the tests and any embedder all share. `'shared'` over
208
+ a per-process store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens, because the failure it
209
+ replaces is silent: the limiter's counters are **per process**, and `docker/helm/values.yaml`
210
+ runs `roles.web.replicas: 3` before its HPA has said anything, so every configured bucket was
211
+ being enforced three times over with a green `x verify`. Nothing here reads the
212
+ environment to guess a replica count — an app that scales is the only thing that knows. The
213
+ supported way to install one is `createServer({ rateLimitStore })`, which builds the limiter
214
+ through `createRateLimiter` and hands it to the `PipelineDeps.limiter` seam that already
215
+ existed; never add a second limiter entry point beside it.
216
+ - **A bucket a route names is a bucket something must register.** `meta.rateLimit` selects by
217
+ name and `meta.rateLimitBucket` carries the numbers; `withRouteBuckets` (`rate-limit-buckets.ts`)
218
+ merges them into `config.rateLimit.buckets` at construction, in `createServer` and again in
219
+ `createPipeline` — idempotently, since the store-backed limiter is built from the merged config
220
+ and `bucketFor` must see the same table the pipeline does. It has to happen there: routes do not
221
+ exist when `defineHttpConfig` builds the table, so a name declared and never registered fell
222
+ through to `default` — an action declaring `limit: 5` ran on 120 burst while its OpenAPI
223
+ operation published 5. **Precedence is refusal, not a winner.** An identical restatement passes;
224
+ any disagreement, with the config or with another route, is `X_RATE_LIMIT_BUCKET_CONFLICT` before
225
+ the socket opens — the same shape as `assertRateLimitScope` and as `@ultimat3/auth`'s
226
+ `AuthLimiter` policy check, and for the same reason: the declaration that lost would go on being
227
+ read as enforced. Never make one side the default winner.
228
+ - **Registering into the config is only half of it — the installed LIMITER must hold the bucket
229
+ too.** `createRateLimiter` closes over its config, so a limiter handed to `PipelineDeps.limiter`
230
+ resolves names against the table it was built with; one built before the routes existed misses
231
+ the route's name, falls through `bucketFor` to `default`, and was measured at 120 burst and 21
232
+ of 21 requests allowed for a route declaring 5. `RateLimiter.buckets` publishes that table —
233
+ declared, never inferred, exactly as `RateLimitStore.scope` is — and `assertRouteBuckets` runs
234
+ beside `assertRateLimitScope` in `createPipeline`. **Refused, never rebound**: a `RateLimiter`
235
+ is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
236
+ caller who built their own may have meant their own numbers. A limiter that declares no table
237
+ is refused too — what cannot be shown to hold is not assumed to hold.
238
+ - Never throw a bare `Error` — use a factory from `errors.ts`.
239
+ - No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
240
+ - Health endpoints answer outside the pipeline, on purpose.
241
+ - **Lifecycle belongs to core.** `server.ts` uses `beginWork()`, `markReady()`,
242
+ `drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
243
+ in-flight counter — core waits on work it does not know about, so a private counter
244
+ hangs every deploy at the `inflight` phase.
245
+ - **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
246
+ `X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
247
+ only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
248
+ unguarded — declaring a borrowed one throws `X_ERROR_CODE_DUPLICATE` at import, which is the
249
+ point. `factsOf` therefore reads a borrowed code's title off the error itself, never the map.
250
+ - Tests must not touch the network — the preload seals `fetch`. Socket tests live in
251
+ `e2e/` and run with `bun test packages/http/e2e`, sealed: `start()` calls core's
252
+ `markListening()`, so the seal treats our own port as self, not egress. Never unseal.
253
+
254
+ ## Files
255
+
256
+ | File | Job |
257
+ |---|---|
258
+ | `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
259
+ | `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
260
+ | `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
261
+ | `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
262
+ | `error-map.ts` | code → status table + `factsOf()` |
263
+ | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
264
+ | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
265
+ | `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
266
+ | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
267
+ | `context.ts` | `RequestContext` + the single `Ctx` adapter (`asCtx`) + the inbound-header readers |
268
+ | `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
269
+ | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
270
+ | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
271
+ | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
272
+ | `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
273
+ | `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
274
+ | `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
275
+ | `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
276
+ | `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
277
+ | `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
278
+ | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
279
+
280
+ ## Commands
281
+
282
+ ```
283
+ bun test packages/http
284
+ bun run --filter @ultimat3/http typecheck
285
+ ```
package/README.md CHANGED
@@ -14,14 +14,18 @@ to skip.
14
14
  | typed request (params, query, body) | `request.ts` |
15
15
  | response constructors + `problem()` | `response.ts` |
16
16
  | code → status, `factsOf()` | `error-map.ts` |
17
- | token-bucket limiting | `rate-limit.ts` |
17
+ | token-bucket limiting, `toBucket` | `rate-limit.ts` |
18
18
  | CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
19
+ | CSRF (origin proof for a credentialed write) | `csrf.ts` |
20
+ | the request deadline and `ctx.signal` | `deadline.ts` |
21
+ | the caller's real address behind a proxy | `forwarded.ts` |
22
+ | the inbound request id and trace, read before the span | `correlation.ts` |
19
23
  | dev error overlay | `overlay.ts` |
20
24
 
21
25
  ## The pipeline is the guarantee
22
26
 
23
27
  ```
24
- request-id → trace → context → locale → auth → rate-limit → body → authz
28
+ request-id → admit → trace → context → locale → auth → rate-limit → csrf → body → authz
25
29
  → handler → cache-headers → (error-map) → response
26
30
  ```
27
31
 
@@ -30,11 +34,103 @@ asserts the order; `/_x` renders it. Ordering rules worth restating:
30
34
 
31
35
  | Rule | Reason |
32
36
  |---|---|
37
+ | admit second | a draining or saturated process refuses before any work — no route match, no auth, no body |
33
38
  | auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
39
+ | csrf after auth | only a caller holding an AMBIENT credential can be forged into; bearer and anonymous are exempt |
40
+ | csrf before body | a forged write never makes the server allocate its payload |
34
41
  | rate-limit before body | a limited request never allocates its payload |
35
42
  | body before authz | policies take parsed input as their subject |
36
43
  | cache-headers before response | a directive can never drop a security header |
37
44
 
45
+ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
46
+
47
+ | Guard | Answer |
48
+ |---|---|
49
+ | a body past `bodyLimitBytes` | read through the stream and abandoned the instant the running total crosses the limit — `content-length` or not, multipart included — as `X_BODY_INVALID` |
50
+ | a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie` |
51
+ | 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 |
52
+ | `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
53
+ | `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test` |
54
+ | HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
55
+ | `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 |
56
+ | 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 |
57
+ | 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 |
58
+ | 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 |
59
+ | `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 |
60
+ | a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an `Origin` in `cors.origins` — anything else is refused before the body is read |
61
+ | a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
62
+ | a request while the process is draining | `X_DRAINING` (503) + `retry-after`, which is what `isDraining()` was always documented to do here and had no reader for |
63
+ | a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
64
+
65
+ `handle()` resolves to a Response, always — a stage that throws after the handler, or while
66
+ rendering another stage's throw, degrades to `X_PIPELINE_FINALIZE_FAILED` (500, the stage named in
67
+ `cause`) and the chain finishes that document instead. `finalize.ts` owns that promise.
68
+
69
+ ## Rate limiting holds where the app says it holds
70
+
71
+ The counters live in a `RateLimitStore`. `memoryRateLimitStore()` is the default and is **one
72
+ process' worth of state**, so a deployment at `replicas: 3` enforces every bucket three times over.
73
+ The app declares which it needs and passes the store that provides it — the store is the only
74
+ thing that knows where its counters live, and a framework that inferred the answer from the
75
+ environment would get it wrong on the first deployment that scaled differently.
76
+
77
+ ```ts
78
+ createServer({
79
+ routes,
80
+ config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
81
+ rateLimitStore: myStore, // whose own scope is 'shared'
82
+ });
83
+ ```
84
+
85
+ | Declared | Store | Result |
86
+ |---|---|---|
87
+ | nothing | any | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` — **breaking, `As of 2026-08`** |
88
+ | `'process'` | any | boots; the limit is per replica, which is what was asked for |
89
+ | `'shared'` | `scope: 'shared'` | boots; one bucket for the fleet |
90
+ | `'shared'` | `scope: 'process'`, or `enabled: false` | `X_RATE_LIMIT_NOT_SHARED` at boot |
91
+
92
+ There is no default. `'process'` used to be one, which made "the app never said" and "the app
93
+ said one replica" the same value while `docker/helm/values.yaml` runs three — and
94
+ `X_RATE_LIMIT_NOT_SHARED` only fires on a `'shared'` declaration, so the silent case was exactly
95
+ the one nobody declared. A limiter with `enabled: false` owes no declaration: nothing is enforced,
96
+ so nothing can be wrong.
97
+
98
+ `rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
99
+ maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships
100
+ yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework.
101
+
102
+ ### A route may bring its own bucket
103
+
104
+ `meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
105
+ `withRouteBuckets` registers them at construction — `createServer` and `createPipeline` both apply
106
+ it, idempotently — because `defineHttpConfig` runs before any route exists and cannot have them.
107
+ Without that half, a name nothing defined fell through `bucketFor` to `default`: an action
108
+ declaring `limit: 5` ran on 120 burst, and the number reached the OpenAPI document all the same.
109
+
110
+ | Declared | Configured under the same name | Result |
111
+ |---|---|---|
112
+ | nothing | — | `default`, unchanged — most routes |
113
+ | numbers | nothing | the route's numbers, registered |
114
+ | numbers | the same numbers | boots; a restatement is not a disagreement |
115
+ | numbers | different numbers | `X_RATE_LIMIT_BUCKET_CONFLICT` at boot |
116
+
117
+ Neither source wins a disagreement, because whichever lost would stay a number an author read and
118
+ nothing enforced. `toBucket` — in **this** package, beside `Bucket` and the maths it validates, because `action` and
119
+ `query` are the same tier and can never import each other — is the one conversion from a
120
+ declaration's
121
+ `{ limit, windowMs }` to a bucket's `{ capacity, refillPerSecond }`, and it refuses a pair the
122
+ limiter could not run on — including one whose two halves look fine and whose **division** does
123
+ not, like `{ limit: Number.MAX_VALUE, windowMs: 1 }` computing to an infinite refill.
124
+
125
+ Registering into the config is only half of it, because the limiter resolves names against the
126
+ table **it** closed over. `RateLimiter.buckets` publishes that table — declared, never inferred,
127
+ the same rule as `RateLimitStore.scope` — and `assertRouteBuckets` compares it against the routes
128
+ at construction. A limiter passed to `PipelineDeps.limiter` that cannot enforce a declared bucket
129
+ is refused rather than rebound: a `RateLimiter` is opaque, so rebinding would mean discarding the
130
+ store it carries, and a caller who built their own limiter may have meant their own numbers. Pass
131
+ the **store** — `createServer({ routes, rateLimitStore })` — and the pipeline builds the limiter
132
+ from the merged table for you.
133
+
38
134
  ## Routing
39
135
 
40
136
  Precedence is structural, not declaration-ordered: **static > param > wildcard**,
@@ -58,6 +154,7 @@ through to `fetch`. Method resolution stays ours so a 405 still carries problem+
58
154
 
59
155
  `X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
60
156
  · `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
157
+ · `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED`
61
158
 
62
159
  One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
63
160
  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": "1.1.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,7 +31,9 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.1.0",
34
- "@ultimat3/schema": "1.1.0"
34
+ "@ultimat3/core": "2.0.0",
35
+ "@ultimat3/i18n": "2.0.0",
36
+ "@ultimat3/schema": "2.0.0",
37
+ "@ultimat3/time": "2.0.0"
35
38
  }
36
39
  }
@@ -0,0 +1,81 @@
1
+ // What an unauthenticated *browser* gets instead of a problem document. An agent and an RPC
2
+ // client want `X_UNAUTHENTICATED` as JSON with a fix line; a person following a link wants the
3
+ // sign-in page. One condition, two audiences, decided here so the error stage stays one branch.
4
+
5
+ import type { RequestContext } from './context';
6
+ import { wantsOverlay } from './overlay';
7
+ import type { RedirectIntent } from './response';
8
+
9
+ /** The query parameter carrying where the visitor was going. One spelling, both halves. */
10
+ export const NEXT_PARAM = 'next';
11
+
12
+ /**
13
+ * Where to send a browser that hit an `auth: 'required'` route with no session, or `undefined`
14
+ * when the problem document is still the right answer.
15
+ *
16
+ * `signInPath` is `null` by default and the redirect is off until an app sets it: a framework
17
+ * that guessed `/signin` would send every unauthenticated visitor of an app that spells it
18
+ * `/login` to a 404, which is strictly worse than the JSON it replaced.
19
+ *
20
+ * 303, not 302: the request that failed authz may have been a form POST, and 303 turns the
21
+ * follow-up into the GET the sign-in page actually is. Same reasoning as `setRedirect`.
22
+ */
23
+ export function signInRedirect(input: {
24
+ readonly code: string;
25
+ readonly signInPath: string | null;
26
+ readonly request: Request;
27
+ readonly ctx: Pick<RequestContext, 'url' | 'method'>;
28
+ }): RedirectIntent | undefined {
29
+ const { code, signInPath, request, ctx } = input;
30
+ if (code !== 'X_UNAUTHENTICATED' || signInPath === null) return undefined;
31
+ // The same question `wantsOverlay` asks — "does this client render HTML?" — and deliberately
32
+ // the same answer, so a client cannot get the overlay in dev and JSON in production.
33
+ if (!wantsOverlay(request)) return undefined;
34
+ // A sign-in page that declares `auth: 'required'` by mistake would otherwise redirect to
35
+ // itself forever, and a browser reports that as a bare "too many redirects" with no code.
36
+ if (ctx.url.pathname === signInPath) return undefined;
37
+ const next = `${ctx.url.pathname}${ctx.url.search}`;
38
+ return { location: `${signInPath}?${NEXT_PARAM}=${encodeURIComponent(next)}`, status: 303 };
39
+ }
40
+
41
+ /**
42
+ * The other half of the round trip: where to send someone once they HAVE signed in.
43
+ *
44
+ * Everything except a same-origin path is refused and `fallback` is used instead. `?next=`
45
+ * arrives from the URL bar, so it is attacker-controlled by definition — an unchecked value here
46
+ * is an open redirect on a page whose entire job is to hold a session, which is the exact shape
47
+ * phishing wants: a real domain, a real login, a hop to somewhere else.
48
+ *
49
+ * Refused: an absolute URL (`https://evil.test/x`), a scheme-relative one (`//evil.test`), a
50
+ * backslash the browser normalises to a slash (`/\evil.test`), a value carrying a TAB, CR or LF,
51
+ * and anything not starting `/`.
52
+ *
53
+ * The control characters are not cosmetic. A browser DELETES tab, CR and LF from a `Location`
54
+ * before it parses one, so `/%09/evil.test` decodes to `/\t/evil.test` — which starts with a
55
+ * single slash, passes a prefix check, and is then parsed as `//evil.test`. The URL parser
56
+ * strips them the same way, which is why the last word here is the parse: whatever a client
57
+ * would actually resolve has to still be a path on this origin.
58
+ */
59
+ export function nextAfterSignIn(raw: string | null | undefined, fallback: string): string {
60
+ if (raw === null || raw === undefined || raw === '') return fallback;
61
+ // `?next=%` is a bare `URIError`, and this runs while the pipeline is already rendering a 401.
62
+ let value: string;
63
+ try {
64
+ value = decodeURIComponent(raw);
65
+ } catch {
66
+ return fallback;
67
+ }
68
+ if (!value.startsWith('/')) return fallback;
69
+ if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
70
+ if (/[\t\r\n]/.test(value)) return fallback;
71
+ // An origin no relative path could reach, so any value that resolves off it left this origin.
72
+ const base = 'http://x.invalid';
73
+ let resolved: URL;
74
+ try {
75
+ resolved = new URL(value, base);
76
+ } catch {
77
+ return fallback;
78
+ }
79
+ if (resolved.origin !== base) return fallback;
80
+ return value;
81
+ }
@@ -0,0 +1,24 @@
1
+ // Single responsibility: what a response may be cached as when neither the handler nor the route
2
+ // declared a hint. Split out of `pipeline.ts` because the answer is not the route's alone — the
3
+ // actor is half of it, and that is the half a cache bug in this framework would come from.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import { isAnonymous } from '@ultimat3/core';
7
+ import type { CacheHint } from './response';
8
+ import type { Route } from './router';
9
+
10
+ /**
11
+ * Authenticated responses are never shared-cacheable; that default is not overridable.
12
+ *
13
+ * The ACTOR decides it, not `meta.auth` alone. `RouteMeta.auth` is `'public' | 'required'`, and the
14
+ * commonest page in any app — public, but greeting you by name when you are signed in — is
15
+ * `'public'`: keying only off the route handed that signed-in user's personalised HTML to a CDN for
16
+ * 60 seconds, which then served it to everyone else. A request carrying an identity is `private`
17
+ * whatever the route says. The other half is `vary: cookie` on the shared path (`response.ts`);
18
+ * either alone leaves the hole open.
19
+ */
20
+ export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint => {
21
+ if (route === undefined || route.meta.auth === 'required') return { mode: 'no-store' };
22
+ if (!isAnonymous(actor)) return { mode: 'private', maxAgeSeconds: 0 };
23
+ return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
24
+ };