@ultimat3/http 23.0.0 → 25.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 +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -54
- package/src/validate.ts +2 -2
package/CLAUDE.md
CHANGED
|
@@ -13,8 +13,9 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
13
13
|
## Rules — configuration
|
|
14
14
|
|
|
15
15
|
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`,
|
|
16
|
-
`requestTimeoutMs`, `maxInflight`, `
|
|
17
|
-
(borrowed).
|
|
16
|
+
`requestTimeoutMs`, `maxInflight`, `trustedProxyHops`) → `X_CONFIG_INVALID`
|
|
17
|
+
(borrowed). `requestTimeoutMs` tops out at `MAX_TIMER_MS` (2^31−1): past it a timer arms ~1 ms.
|
|
18
|
+
`buildId: null` is a declaration (`=== undefined`, never `??`); `hostname` never reads `HOSTNAME`. Helpers carry `Finite` (`assertFiniteCount`, `assertFiniteKeyCap`,
|
|
18
19
|
`assertFiniteBodyLimit`) so `bun run finite-bounds` sees them; `webhook-verify.ts` and
|
|
19
20
|
`rate-limit.ts` screen their own. **The floor is per option**: `requestTimeoutMs: 0` and
|
|
20
21
|
`maxInflight: 0` are "off"; `trustedProxyHops` floors at 1. `resolveTrustedProxyHops` owns both
|
|
@@ -27,22 +28,25 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
27
28
|
`trustedProxyHops`, `rateLimit.scope`) is a type error where an app writes it. `mergeHttpConfig`
|
|
28
29
|
merges `security.csp.extend` per directive. `type-pins.ts` refuses a key on `HttpConfig` missing
|
|
29
30
|
from `HttpConfigInput`.
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
`readinessGraceMs
|
|
31
|
+
- **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role). Key
|
|
32
|
+
`drainTimeoutMs` (deleted 25.0.0) is `X_CONFIG_INVALID` via `refuseDeletedHttpKeys`.
|
|
33
|
+
**`ServerOptions.drain`** passes only `readinessGraceMs`, and only when declared.
|
|
33
34
|
- **`cors.origins: ['*']` with `credentials: true` is `X_CORS_CONFIG_INVALID`.** A refused origin
|
|
34
35
|
still gets `vary: origin`.
|
|
35
36
|
- **A `security.csp.extend` entry must emit only the directive it names**
|
|
36
37
|
(`X_CSP_DIRECTIVE_INVALID`); `buildCsp` builds through a `Map`.
|
|
37
38
|
- **The security headers are built once per `(SecurityConfig, https)`** (`responseSecurityHeaders`, a
|
|
38
39
|
`WeakMap`); a config is never mutated after `defineHttpConfig`. HSTS only when `ctx.https` is affirmed.
|
|
40
|
+
- **A `content-security-policy` the handler set is kept**: the `response` stage APPENDS the app's
|
|
41
|
+
policy beside it (two policies, both enforced), so a handler can narrow — `sandbox` on a served
|
|
42
|
+
upload — and never widen. Every other security header is set over the handler's (`stages-csp.test.ts`).
|
|
39
43
|
|
|
40
44
|
## Rules — the context
|
|
41
45
|
|
|
42
46
|
- **`asCtx` is a WIDENING the compiler checks, never a cast** (`RequestContext extends Ctx`;
|
|
43
47
|
`_RequestContextIsACtx` in `type-pins.ts`). `ctx.buildId` is the process's build;
|
|
44
48
|
`ctx.clientBuildId` is the client's claim, read only by `assertBuild()`.
|
|
45
|
-
- **`
|
|
49
|
+
- **`requestContext` COMPOSES `ctxOf()`** — no assertion in this file, and
|
|
46
50
|
`defineService` factories and the service spread are core's. Services go on FIRST so a service
|
|
47
51
|
named `actor` loses to the request's field (`context.test.ts`).
|
|
48
52
|
- **A request's registered services are LAZY and bound to the authenticated actor**
|
|
@@ -64,8 +68,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
64
68
|
handler, policy, idempotency; adds the credential (bearer only, `WWW-Authenticate` on 401), the
|
|
65
69
|
cut (outside the token's scopes = `X_ROUTE_NOT_FOUND`, the MCP rule), and a per-token bucket
|
|
66
70
|
keyed by a SHA-256 of the token. Bad prefix / unknown name / double claim:
|
|
67
|
-
`X_BEARER_MOUNT_INVALID` at construction.
|
|
71
|
+
`X_BEARER_MOUNT_INVALID` at construction. **The cut is decided in the `auth` stage** — one
|
|
72
|
+
authenticator per route, closed over its scope — so an out-of-scope token is 404 before `body`
|
|
73
|
+
(422) and `authz` (403) could describe the primitive. **The prefix is opaque**: `<prefix>/*rest`
|
|
74
|
+
for every method (`opaquePrefix`) answers no token / a bad token with the same 401 challenge a
|
|
75
|
+
served path gives, and a valid token with the same 404 — never 404-vs-401 or a 405 naming methods.
|
|
76
|
+
That 404 SPENDS the token's allowance (once; a served call is charged in the handler), so a
|
|
77
|
+
token cannot walk the cut for free.
|
|
68
78
|
- **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
|
|
79
|
+
- **A dev 4xx asks `hooks.errorPage` first; a 5xx is always the overlay** (#492): the app's file
|
|
80
|
+
when it has one, else the overlay. Production asks for every status (`stages-error-page.test.ts`).
|
|
69
81
|
|
|
70
82
|
## Rules — the pipeline
|
|
71
83
|
|
|
@@ -82,28 +94,35 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
82
94
|
- **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
|
|
83
95
|
`parseTraceparent`). `x-request-id` is gated on `trustProxy`; `traceparent` deliberately is not.
|
|
84
96
|
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`** — the entry at
|
|
85
|
-
`entries.length - hops`, never `[0]`; a short chain trusts nothing. `
|
|
97
|
+
`entries.length - hops`, never `[0]`; a short chain trusts nothing. **Except `x-forwarded-proto`**
|
|
98
|
+
(`forwardedProto`): proxies usually OVERWRITE it, so a list shorter than `hops` reads its first
|
|
99
|
+
entry (an overwrite erased the client's value; what is left a trusted hop wrote) — HSTS behind
|
|
100
|
+
two hops. A full-length list is read like an address. `trustProxy` defaults to false
|
|
86
101
|
and requires `trustedProxyHops`. `x-forwarded-proto` (HSTS) and Envoy XFCC (`peer-identity.ts`) ride
|
|
87
|
-
it; `ctx.peer` is `null` unless trusted
|
|
102
|
+
it; `ctx.peer` is `null` unless trusted AND `trustClientCertHeader` is declared (appending to
|
|
103
|
+
XFF is no promise of stripping a client-sent cert header), and is never an actor.
|
|
88
104
|
- **One deadline per request** (`deadline.ts`): `requestTimeoutMs` (30 s, `0` disables), shortenable
|
|
89
105
|
by `x-request-timeout-ms`, never lengthened. `ctx.signal` is the deadline OR the caller going away
|
|
90
106
|
(`AbortSignal.any` with `Request.signal`); `expired` is the timer's alone. `Deadline.deadlineAt` is
|
|
91
107
|
published as core's `ctx.deadlineAt`, which `traceHeaders()` sends onward. Always `deadline.clear()`
|
|
92
|
-
in the `finally`.
|
|
108
|
+
in the `finally`. An ask above `MAX_TIMER_MS` is ignored. **A handler that outlives its 504 is held
|
|
109
|
+
in core's in-flight count** (`beginWork()` in `execute`) until it settles (`pipeline-inflight.test.ts`).
|
|
93
110
|
- **`admit` is the second stage and refuses before ANY work**: past `maxInflight` (1000) is
|
|
94
111
|
`X_OVERLOADED` with `retry-after`, counted by core's `inflightCount()`. **A DRAINING process
|
|
95
112
|
serves** with `connection: close`; only `lifecycleState() === 'stopped'` answers `X_DRAINING`
|
|
96
113
|
(`pipeline-hardening.test.ts`).
|
|
97
|
-
- **`csrf` sits after `auth` and before `body`**: an
|
|
114
|
+
- **`csrf` sits after `auth` and before `body`**: an unsafe request needs
|
|
98
115
|
`sec-fetch-site: same-origin`, an `Origin` equal to this app (from `ctx.https`), or one EXACTLY in
|
|
99
116
|
`cors.origins` (`originListed`, never `allowedOrigin`); else `X_CSRF_BLOCKED` (403). No
|
|
100
|
-
`mode: 'token'`.
|
|
117
|
+
`mode: 'token'`. **Anonymous is not an exemption** (login CSRF): exempt are an `Authorization`
|
|
118
|
+
header, and an anonymous request with NEITHER `Origin` nor `sec-fetch-site` (not a browser).
|
|
101
119
|
- **`meta.enforcedBy` says who evaluates `meta.policy`**: `'pipeline'` (default) decides via
|
|
102
120
|
`hooks.authorize`; `'handler'` stands the `authz` stage down.
|
|
103
121
|
- **A 403's `fix:` names the POLICY** (`route.meta.policy`), degrading to `x routes --json` for a
|
|
104
122
|
composite.
|
|
105
123
|
- **The body cap is enforced while reading** (`UltimateRequest.#read`'s counting reader, cancelling
|
|
106
|
-
past `bodyLimitBytes`); multipart goes through the same capped bytes.
|
|
124
|
+
past `bodyLimitBytes`); multipart goes through the same capped bytes. **Bytes with no
|
|
125
|
+
`content-type` are `X_BODY_INVALID`** from `bodyRaw()`, never "no body"; `bodyBytes()` reads them.
|
|
107
126
|
- **A repeated field is a LIST in all three parsers** (`collectFields`).
|
|
108
127
|
- **`matchRoute` never throws**: `router.ts`'s `decodeSegment` answers `path-invalid` →
|
|
109
128
|
`X_PATH_INVALID` (400).
|
|
@@ -120,13 +139,21 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
120
139
|
- **The `cache-headers` stage is the ONE owner of the final cache answer**: `offersSharedCache` turns a
|
|
121
140
|
shared answer for an identified request into `PRIVATE_CACHE` and gives an anonymous one
|
|
122
141
|
`SHARED_CACHE_VARY` (`accept-language, cookie`); `immutable` is left alone. `vary` is added, never
|
|
123
|
-
set (`addVary`).
|
|
142
|
+
set (`addVary`). An offer is `public`, `s-maxage`, or any `max-age` / `must-revalidate` /
|
|
143
|
+
`proxy-revalidate` without `private`/`no-store` (RFC 9111 §3).
|
|
144
|
+
- **`Set-Cookie` is appended by the `response` stage, every other context header is `set`** —
|
|
145
|
+
it is the one header that is a list of lines (`pipeline-cookies.test.ts`). App code writes one
|
|
146
|
+
only via `setCookie`/`deleteCookie` (core's serializer; `bun run set-cookie-literals`).
|
|
147
|
+
- **A redirect the framework writes stays on this origin**: the default-locale redirect goes
|
|
148
|
+
through `normalizePath`; `locationFor` hands over `http:`/`https:` only, else the requested path.
|
|
124
149
|
- **A `cache-control` age is delta-seconds or DROPPED** (`finiteDeltaSeconds`, total, always the
|
|
125
150
|
shorter direction; logs `http.cache_hint_not_delta_seconds`). Its boot half is `route-cache.ts`:
|
|
126
|
-
`
|
|
151
|
+
`httpRouter` refuses a bad `Route.cache` with `X_CONFIG_INVALID`. Both accept the same set; zero
|
|
127
152
|
is legal. `ctx.cache` is not screened.
|
|
128
153
|
- **A handler's own `Response.status` is never rewritten** (`pipeline-handler-status.test.ts`).
|
|
129
|
-
- Health endpoints answer outside the pipeline
|
|
154
|
+
- Health endpoints answer outside the pipeline, so their body is a stranger's: `{ state, ready,
|
|
155
|
+
role }` unless the peer is in `healthDetailPeers` (default `['loopback']`; socket AND trusted
|
|
156
|
+
forwarded caller must both be listed — `health-disclosure.ts`). **Lifecycle belongs to core** (`beginWork()`,
|
|
130
157
|
`markReady()`, `drain()`, the payloads) — never a private state or in-flight counter.
|
|
131
158
|
- **`stop()` hands its two hooks back ABOVE its early return** (`packages/http/e2e/server.e2e.test.ts`
|
|
132
159
|
reads `shutdownHookCount()`).
|
|
@@ -140,7 +167,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
140
167
|
`ERROR_DOCS_URL`.** `finalize.ts`'s `lastResort` literal is pinned against `problemTypeFor('X_INTERNAL')`.
|
|
141
168
|
- **A 5xx cause is withheld unless its code opts in** (`hasPublicCause`, `problem-meta.ts`;
|
|
142
169
|
`registerProblemMeta({ CODE: { publicCause: true } })`). `toProblem(error, { dev })` defaults `dev`
|
|
143
|
-
to false. An unclassified 5xx carries nothing off the throwable.
|
|
170
|
+
to false. An unclassified 5xx carries nothing off the throwable. **A hidden cause hides the `fix`
|
|
171
|
+
too**: `callerFix`, else `x errors explain <CODE> --json` — never `facts.fix` outside dev.
|
|
144
172
|
- **The document carries the ISSUE LIST** as a top-level `issues` member: `issuesOf` is total,
|
|
145
173
|
all-or-nothing, absent (never `[]`) when none, dropped past `MAX_PROBLEM_ISSUES` (100), dropped under
|
|
146
174
|
the opacity condition; `received` forced to `''`, entries rebuilt member by member.
|
|
@@ -152,8 +180,11 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
152
180
|
vocabulary; what the caller sent rides in `meta`; the parser's message goes through `renderThrowable`.
|
|
153
181
|
- **A browser failing `auth: 'required'` is redirected; an agent gets the problem document**
|
|
154
182
|
(`auth-redirect.ts`, in `error-map` before the overlay). `config.signInPath` is `null` until named.
|
|
155
|
-
`nextAfterSignIn` is the ONE `?next=` reader: same-origin paths only,
|
|
156
|
-
|
|
183
|
+
`nextAfterSignIn` is the ONE `?next=` reader: same-origin paths only, decoded ONCE (by the
|
|
184
|
+
caller's parser, never again here — `%26` stays `%26`), any C0/DEL refused, non-ASCII
|
|
185
|
+
percent-encoded for `Location`, re-parsed against an unreachable origin, never throws. It
|
|
186
|
+
returns the RESOLVED path (never the raw string) and refuses one starting `//`; `locationFor`
|
|
187
|
+
never emits a same-origin pathname starting `//` (`/.//…` instead).
|
|
157
188
|
- **A pathname in a `fix:` goes through `renderFixShellArg`** (`routeNotFound`). **A `code` is gated by
|
|
158
189
|
core's `FRAMEWORK_CODE`** before `x errors explain <code>`, else `x errors list --json`. **A supplied
|
|
159
190
|
`fix:` is taken only from a branded error** (`isUltimateError`).
|
|
@@ -169,15 +200,22 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
169
200
|
`X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN`. Headers report the bucket closest to refusing.
|
|
170
201
|
- **The memory store is bounded** (`forgetAtMs` sweep, `DEFAULT_MAX_RATE_LIMIT_KEYS`, evicting the
|
|
171
202
|
entries closest to FULL first — never LRU).
|
|
172
|
-
- **The limiter takes a `Clock`** (`
|
|
203
|
+
- **The limiter takes a `Clock`** (`rateLimiter({ clock })`); `deps.limiter` is the one seam.
|
|
173
204
|
- **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
|
|
174
|
-
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `
|
|
175
|
-
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `
|
|
176
|
-
-
|
|
205
|
+
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `httpPipeline`) refuses `'shared'` over a
|
|
206
|
+
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `httpServer({ rateLimitStore })`.
|
|
207
|
+
- **A failed `auth: 'required'` is metered in the `auth` stage** (`spendUnauthenticated`,
|
|
208
|
+
`rate-limit-stage.ts`): `defaultBucket` under `unauthenticated|ip:<address>`, not route-scoped,
|
|
209
|
+
spent only on failure — the 401 leaves before `rate-limit`. Covers the bearer mount's bad tokens.
|
|
210
|
+
Spent, it REFUSES the address before `authenticate()` (`refuseExhaustedAddress`, via the store's
|
|
211
|
+
read-only `peek` — never a take: that was an upsert per signed-in page),
|
|
212
|
+
a valid credential included; no `ctx.ip`, no gate. Signed-in callers behind that NAT wait too.
|
|
213
|
+
- **`postgresRateLimitStore({ executor })` is the shared store**, over `@ultimat3/core`'s structural
|
|
214
|
+
`PgExecutor` (re-exported here). The
|
|
177
215
|
refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
|
|
178
216
|
`purgeExpired(nowMs)` takes the caller's clock.
|
|
179
217
|
- **A bucket a route names must be registered**: `withRouteBuckets` merges `meta.rateLimitBucket`
|
|
180
|
-
into the table in `
|
|
218
|
+
into the table in `httpServer` and `httpPipeline`; any disagreement is
|
|
181
219
|
`X_RATE_LIMIT_BUCKET_CONFLICT`. **The installed limiter must hold it too** — `RateLimiter.buckets`
|
|
182
220
|
is declared and `assertRouteBuckets` refuses, never rebinds.
|
|
183
221
|
|
|
@@ -199,32 +237,38 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
199
237
|
| `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
|
|
200
238
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
201
239
|
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
202
|
-
| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
|
|
240
|
+
| `router.ts` | trie matcher, precedence 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 (`/precios-espa%C3%B1a` is `/precios-españa`). A wildcard takes an EMPTY rest after the node's own routes (`/docs` → `/docs/*path` with `path: ''`, the path `@ultimat3/render`'s static build writes) — `router-spelling.test.ts` |
|
|
203
241
|
| `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 |
|
|
204
|
-
| `error-map.ts` | the code → status table, closed
|
|
242
|
+
| `error-map.ts` | the code → status table, closed: COMPOSES the slices and holds no row of its own |
|
|
243
|
+
| `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` |
|
|
205
244
|
| `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 |
|
|
206
245
|
| `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()` |
|
|
207
246
|
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
208
247
|
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
209
248
|
| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
|
|
210
|
-
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `
|
|
249
|
+
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `ctxOf`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
|
|
211
250
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
251
|
+
| `set-cookie.ts` | `setCookie`/`deleteCookie`: one line each onto `ctx.headers` |
|
|
212
252
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
213
253
|
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
|
|
214
254
|
| `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 |
|
|
215
|
-
| `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `
|
|
255
|
+
| `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 |
|
|
216
256
|
| `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
|
|
217
257
|
| `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
|
|
218
|
-
| `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling
|
|
258
|
+
| `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling |
|
|
259
|
+
| `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 |
|
|
219
260
|
| `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
|
|
220
261
|
| `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
|
|
221
262
|
| `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
|
|
222
263
|
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
223
|
-
| `csrf.ts` | the origin proof an unsafe method from a
|
|
264
|
+
| `csrf.ts` | the origin proof an unsafe method from a browser must carry, signed in or not |
|
|
224
265
|
| `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 |
|
|
225
|
-
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only
|
|
266
|
+
| `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 |
|
|
226
267
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
268
|
+
| `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 |
|
|
269
|
+
| `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 |
|
|
227
270
|
| `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
|
|
271
|
+
| `request-facts.ts` | `RequestFacts`, any token resolver's frozen 2nd arg; address = `ctx.ip` |
|
|
228
272
|
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
229
273
|
|
|
230
274
|
## Commands
|
package/README.md
CHANGED
|
@@ -8,17 +8,17 @@ to skip.
|
|
|
8
8
|
|
|
9
9
|
| Concern | Module |
|
|
10
10
|
|---|---|
|
|
11
|
-
| server lifecycle, drain, `/healthz` + `/readyz` | `server.ts` |
|
|
11
|
+
| server lifecycle, drain, `/healthz` + `/readyz` | `server.ts`; `health-disclosure.ts` owns the `healthDetailPeers` key, its screen and the proxy half — the body rule is `@ultimat3/core`'s |
|
|
12
12
|
| route table, matcher, `describeRoutes()` | `router.ts` |
|
|
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, closed table | `error-map.ts` |
|
|
16
|
+
| code → status, closed table | `error-map.ts`, composing `error-map-http.ts` + `error-map-tier-<n>.ts` |
|
|
17
17
|
| `factsOf()`, the problem document, the terminal lines | `error-facts.ts` |
|
|
18
18
|
| token-bucket limiting, `toBucket` | `rate-limit.ts` |
|
|
19
19
|
| the app's own HTTP declaration, and the boot's facts over it | `app-config.ts` |
|
|
20
20
|
| CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
|
|
21
|
-
| CSRF (origin proof for a
|
|
21
|
+
| CSRF (origin proof for an unsafe request a browser sent) | `csrf.ts` |
|
|
22
22
|
| the request deadline and `ctx.signal` | `deadline.ts` |
|
|
23
23
|
| the caller's real address behind a proxy | `forwarded.ts` |
|
|
24
24
|
| the inbound request id and trace, read before the span | `correlation.ts` |
|
|
@@ -68,8 +68,8 @@ asserts the order; `/_x` renders it. Ordering rules worth restating:
|
|
|
68
68
|
| Rule | Reason |
|
|
69
69
|
|---|---|
|
|
70
70
|
| admit second | a stopped or saturated process refuses before any work — no route match, no auth, no body; a DRAINING one serves and closes the connection |
|
|
71
|
-
| auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
|
|
72
|
-
| csrf after auth |
|
|
71
|
+
| auth before rate-limit | limiter keys per actor/tenant, not per NAT address. A request that FAILS `auth: 'required'` leaves here, so this stage spends `rateLimit.defaultBucket` for it under `unauthenticated\|ip:<address>` — one key per address across every route, the bearer mount's bad tokens included; a signed-in caller behind that address spends nothing from it |
|
|
72
|
+
| csrf after auth | it has to know who is calling: a bearer caller is exempt (not ambient); an anonymous write is judged like a signed-in one once it carries `Origin` or `sec-fetch-site`, because a forged sign-in lands the visitor in the attacker's account. Anonymous with neither header — a webhook, a server-to-server call — passes |
|
|
73
73
|
| csrf before body | a forged write never makes the server allocate its payload |
|
|
74
74
|
| rate-limit before body | a limited request never allocates its payload |
|
|
75
75
|
| body before authz | policies take parsed input as their subject |
|
|
@@ -87,21 +87,31 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
87
87
|
| 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 |
|
|
88
88
|
| 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 |
|
|
89
89
|
| `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
|
|
90
|
-
| `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test` |
|
|
90
|
+
| `?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 |
|
|
91
91
|
| HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
|
|
92
|
-
| `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `
|
|
93
|
-
| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `
|
|
92
|
+
| `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 |
|
|
93
|
+
| 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 |
|
|
94
94
|
| 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 |
|
|
95
|
-
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `
|
|
95
|
+
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `httpPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
|
|
96
96
|
| 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 |
|
|
97
97
|
| `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 |
|
|
98
|
-
|
|
|
99
|
-
| 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 |
|
|
98
|
+
| an unsafe method from a browser — signed in, or anonymous with `Origin`/`sec-fetch-site` — that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an EXACT listing in `cors.origins` — anything else is refused before the body is read. `origins: ['*']` lists nobody here: `'*'` is a value for the response header, not a per-origin allowance |
|
|
99
|
+
| 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. A handler still running after the 504 stays in core's in-flight count until it settles, so a drain waits for it instead of closing the pool under it |
|
|
100
|
+
| a budget above `2_147_483_647` ms | no timer holds it: `requestTimeoutMs` past it is `X_CONFIG_INVALID`, and an `x-request-timeout-ms` past it is ignored — it used to arm a ~1 ms timer |
|
|
100
101
|
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
101
102
|
| a request while the process is draining | SERVED, with `connection: close` so the client's next request lands on another pod, `As of 2026-09-23` — refusing it failed 598 of 7,690 requests across one helm upgrade on kind, every one on a kept-alive connection inside the readiness grace. Only a STOPPED process (resources closed) answers `X_DRAINING` (503) + `retry-after` |
|
|
102
|
-
| SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `
|
|
103
|
+
| SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `httpServer({ …, drain: appConfig.drain })` so `app.config.ts`'s value is the one applied; omitted, core's default holds and a `configureLifecycle({ readinessGraceMs })` stands |
|
|
103
104
|
| `?locale=es` | the locale source that outranks the cookie and the header (`resolveLocale`'s order), `As of 2026-09-23` — documented in `wiki/I18n.md` and never read before |
|
|
104
105
|
| a 4xx | logged at `warn` (401, 403, 429) or `info` (every other 4xx); only a 5xx is an `error` line |
|
|
106
|
+
| `x-forwarded-client-cert` without `trustClientCertHeader: true` | `ctx.peer` is `null`. **Breaking**: `trustProxy` alone used to read it, and an ingress that appends to `x-forwarded-for` need not strip a certificate header the client sent |
|
|
107
|
+
| `/healthz`, `/readyz` from a peer `healthDetailPeers` does not list | `{ state, ready, role }` and the same status code; `buildId`, `inflight`, `uptimeMs`, `checks` and `registered` go to a listed peer only (default `['loopback']`), and behind a declared proxy the forwarded caller must be listed too |
|
|
108
|
+
| a `/<default-locale>/…` URL | `301`/`308` to the unprefixed path, normalised as the router matches it — `//host/x` after the prefix is `/host/x`, never a scheme-relative `Location` |
|
|
109
|
+
| a 3xx to a client-router request whose target is not `http:`/`https:` | `x-ultimate-location` names the REQUESTED url as a path; the target is never handed over, and one that will not parse is treated the same |
|
|
110
|
+
| a handler's `cache-control` carrying `max-age`, `must-revalidate` or `proxy-revalidate` with no `private`/`no-store` | an offer to a shared cache (RFC 9111 §3): `private, max-age=0` for a request carrying an identity, `vary`-keyed for an anonymous one. `immutable` stays exempt |
|
|
111
|
+
| a body with no `content-type` | `X_BODY_INVALID` from `bodyRaw()` / the `body` stage — it used to read as "no body", so an all-optional schema validated a request nobody parsed. `bodyBytes()` still reads it |
|
|
112
|
+
| two cookies set in one request | two `Set-Cookie` lines: the `response` stage appends them, where `headers.set` in a loop kept the last and dropped any on the handler's own Response |
|
|
113
|
+
| a 5xx whose cause is withheld | its `fix` is the error's `callerFix`, else `x errors explain <CODE> --json` — never the developer's `fix:`, which is written from the same statement, row or path the cause was |
|
|
114
|
+
| `buildId: null` | skew detection off, whatever `BUILD_ID` says; `hostname` never defaults from `HOSTNAME` (the container id under Docker) |
|
|
105
115
|
| 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` |
|
|
106
116
|
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
107
117
|
|
|
@@ -117,9 +127,14 @@ The app declares which it needs and passes the store that provides it — the st
|
|
|
117
127
|
thing that knows where its counters live, and a framework that inferred the answer from the
|
|
118
128
|
environment would get it wrong on the first deployment that scaled differently.
|
|
119
129
|
|
|
130
|
+
A store answers three calls: `take(key, bucket, cost, nowMs)` spends, `peek(key, bucket, nowMs)`
|
|
131
|
+
reads (refill applied, **no write and no row** — a key never taken is full; `rateLimitPeek` and
|
|
132
|
+
`refilledTokens` do the arithmetic) and `reset(key)`. `peek` is required, `As of 2026-10`: the
|
|
133
|
+
`auth` stage asks it on every request to an `auth: 'required'` route.
|
|
134
|
+
|
|
120
135
|
```ts
|
|
121
136
|
import {
|
|
122
|
-
|
|
137
|
+
httpServer,
|
|
123
138
|
defineHttpConfig,
|
|
124
139
|
type RateLimitStore,
|
|
125
140
|
type Route,
|
|
@@ -128,7 +143,7 @@ import {
|
|
|
128
143
|
declare const routes: readonly Route[];
|
|
129
144
|
declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
|
|
130
145
|
|
|
131
|
-
|
|
146
|
+
httpServer({
|
|
132
147
|
routes,
|
|
133
148
|
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
|
|
134
149
|
rateLimitStore: myStore, // whose own scope is 'shared'
|
|
@@ -149,7 +164,7 @@ the one nobody declared. A limiter with `enabled: false` owes no declaration: no
|
|
|
149
164
|
so nothing can be wrong.
|
|
150
165
|
|
|
151
166
|
`rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
|
|
152
|
-
maths stays in `
|
|
167
|
+
maths stays in `rateLimiter`, so every driver agrees on the numbers.
|
|
153
168
|
|
|
154
169
|
**One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
|
|
155
170
|
else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
|
|
@@ -164,7 +179,7 @@ one tenant is a person and the next is five thousand seats, so there is no allow
|
|
|
164
179
|
can pick for you.
|
|
165
180
|
|
|
166
181
|
**A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table
|
|
167
|
-
and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,
|
|
182
|
+
and one `insert … on conflict` per take (a peek is one `select`), so N replicas count against one bucket. Until it landed,
|
|
168
183
|
`scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
|
|
169
184
|
`replicas: 2`. `executor` is a `PgExecutor` — anything speaking `query(text, values)`, which is one
|
|
170
185
|
line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`.
|
|
@@ -172,7 +187,7 @@ line over the client the boot already opened; **never `Bun.sql`**, whose `.query
|
|
|
172
187
|
```ts
|
|
173
188
|
import { db, type SqlFragment } from '@ultimat3/db';
|
|
174
189
|
import {
|
|
175
|
-
|
|
190
|
+
httpServer,
|
|
176
191
|
defineHttpConfig,
|
|
177
192
|
type PgExecutor,
|
|
178
193
|
postgresRateLimitStore,
|
|
@@ -189,7 +204,7 @@ const executor: PgExecutor = {
|
|
|
189
204
|
client.query<R>({ text, values } satisfies SqlFragment),
|
|
190
205
|
};
|
|
191
206
|
|
|
192
|
-
|
|
207
|
+
httpServer({
|
|
193
208
|
routes,
|
|
194
209
|
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }),
|
|
195
210
|
rateLimitStore: postgresRateLimitStore({ executor }),
|
|
@@ -202,7 +217,7 @@ rule. `nowMs` is required and must come from the same clock the takes use — me
|
|
|
202
217
|
server's clock instead, the offset between the two reads as refill and deletes buckets a throttled
|
|
203
218
|
caller is still sitting in.
|
|
204
219
|
|
|
205
|
-
The maths reads an injected `Clock`, defaulting to `systemClock`: `
|
|
220
|
+
The maths reads an injected `Clock`, defaulting to `systemClock`: `rateLimiter({ config,
|
|
206
221
|
clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read
|
|
207
222
|
`Date.now()` when nothing passed one, which both production call sites did, so the limiter that
|
|
208
223
|
actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
@@ -212,7 +227,7 @@ actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
|
212
227
|
### A route may bring its own bucket
|
|
213
228
|
|
|
214
229
|
`meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
|
|
215
|
-
`withRouteBuckets` registers them at construction — `
|
|
230
|
+
`withRouteBuckets` registers them at construction — `httpServer` and `httpPipeline` both apply
|
|
216
231
|
it, idempotently — because `defineHttpConfig` runs before any route exists and cannot have them.
|
|
217
232
|
Without that half, a name nothing defined fell through `bucketFor` to `default`: an action
|
|
218
233
|
declaring `limit: 5` ran on 120 burst, and the number reached the OpenAPI document all the same.
|
|
@@ -238,7 +253,7 @@ the same rule as `RateLimitStore.scope` — and `assertRouteBuckets` compares it
|
|
|
238
253
|
at construction. A limiter passed to `PipelineDeps.limiter` that cannot enforce a declared bucket
|
|
239
254
|
is refused rather than rebound: a `RateLimiter` is opaque, so rebinding would mean discarding the
|
|
240
255
|
store it carries, and a caller who built their own limiter may have meant their own numbers. Pass
|
|
241
|
-
the **store** — `
|
|
256
|
+
the **store** — `httpServer({ routes, rateLimitStore })` — and the pipeline builds the limiter
|
|
242
257
|
from the merged table for you.
|
|
243
258
|
|
|
244
259
|
## Routing
|
|
@@ -249,19 +264,25 @@ flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** — a ro
|
|
|
249
264
|
cannot forget to declare its auth posture.
|
|
250
265
|
|
|
251
266
|
```ts
|
|
252
|
-
import {
|
|
267
|
+
import { httpServer, defineHttpConfig, jsonResponse } from '@ultimat3/http';
|
|
253
268
|
|
|
254
|
-
const handle =
|
|
269
|
+
const handle = httpServer({
|
|
255
270
|
routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
|
|
256
|
-
handler: (req) =>
|
|
271
|
+
handler: (req) => jsonResponse({ id: req.param('id') }) }],
|
|
257
272
|
config: defineHttpConfig({ port: 3000 }),
|
|
258
273
|
role: 'web',
|
|
259
274
|
}).start();
|
|
260
275
|
```
|
|
261
276
|
|
|
262
|
-
Static paths are registered in Bun's native `routes` table
|
|
277
|
+
Static paths are registered in Bun's native `routes` table (keyed `encodeURI`'d: `Bun.serve`
|
|
278
|
+
refuses a non-ASCII key); param/wildcard paths, and any other spelling of a static one, fall
|
|
263
279
|
through to `fetch`. Method resolution stays ours so a 405 still carries problem+json.
|
|
264
280
|
|
|
281
|
+
`As of 2026-10-02`: a literal segment matches its percent-encoded request spelling, either hex
|
|
282
|
+
case (`/precios-espa%C3%B1a` reaches `/precios-españa`), and a wildcard matches an empty rest —
|
|
283
|
+
`/docs` and `/docs/` reach `/docs/*path` with `path: ''`, after a static `/docs` route if one is
|
|
284
|
+
declared. A malformed escape is still `X_PATH_INVALID` or a 404, never a thrown `URIError`.
|
|
285
|
+
|
|
265
286
|
### No WebSocket upgrade on the `web` role, and no hand-written `api/` route
|
|
266
287
|
|
|
267
288
|
`As of 2026-09-05`, and stated because an app went looking for both. The `web` role's
|
|
@@ -306,6 +327,12 @@ exactly three things. Apps declare it through `defineApi({ http: { mounts } })`
|
|
|
306
327
|
For API keys, `resolveToken` is `@ultimat3/auth`'s `apiKeyResolver(() => store)` — a live key is
|
|
307
328
|
`{ actor, scopes }`, every wrong one is the same `null`, and a store fault stays a throw.
|
|
308
329
|
|
|
330
|
+
`resolveToken(token, facts)` (`As of 2026-10`): `facts` is `RequestFacts` — `{ address, userAgent,
|
|
331
|
+
origin, path }`, frozen, built by `requestFacts()`. `address` is `ctx.ip`, the pipeline's
|
|
332
|
+
trusted-hop answer, never a forwarding header. `@ultimat3/mcp` hands its resolver the same object
|
|
333
|
+
(`McpRequestFacts`), so one resolver can bind a token to a network or refuse a browser `Origin` on
|
|
334
|
+
both doors. A one-argument resolver still works.
|
|
335
|
+
|
|
309
336
|
## Inbound webhooks
|
|
310
337
|
|
|
311
338
|
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
|
|
@@ -348,7 +375,10 @@ well formed and carried a credential, and the credential is what failed. Neither
|
|
|
348
375
|
sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
|
|
349
376
|
|
|
350
377
|
The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
|
|
351
|
-
|
|
378
|
+
wire format — the canonical string, `WEBHOOK_ID_HEADER`, `WEBHOOK_SIGNATURE_HEADER`,
|
|
379
|
+
`WEBHOOK_TOPIC_HEADER`, `WEBHOOK_SIGNATURE_VERSION` — is `@ultimat3/core`'s
|
|
380
|
+
(`webhook-signature.ts`), and a receiver imports the names from there: 25.0.0 dropped this
|
|
381
|
+
package's re-exports of them, and of `escapeHtml` and `readCookie` (`X_HELPER_COPY`).
|
|
352
382
|
|
|
353
383
|
## The exact body bytes
|
|
354
384
|
|
|
@@ -383,6 +413,39 @@ export const ingestSesEvent = action({
|
|
|
383
413
|
| one reader | `bodyRaw()` and `bodyBytes()` share one capped read, so an action that reads both reads the stream once |
|
|
384
414
|
| off HTTP | `X_NO_REQUEST`: a job or an MCP call has no body bytes |
|
|
385
415
|
|
|
416
|
+
## Setting a cookie
|
|
417
|
+
|
|
418
|
+
`As of 2026-10-06`. A route handler, a page `load()` or an action cannot hand the pipeline a
|
|
419
|
+
`Response` of its own, so it calls `setCookie` on the request in scope. The line is
|
|
420
|
+
`@ultimat3/core`'s `serializeSetCookie` — the one serializer — appended to the context's response
|
|
421
|
+
headers, which the `response` stage appends to whatever is sent.
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
import { action, t } from '@ultimat3/action';
|
|
425
|
+
import { deleteCookie, setCookie } from '@ultimat3/http';
|
|
426
|
+
import { allow } from '@ultimat3/policy';
|
|
427
|
+
|
|
428
|
+
export const choosePreferences = action({
|
|
429
|
+
input: t.object({ theme: t.string.max(16), reset: t.boolean }),
|
|
430
|
+
output: t.object({ ok: t.boolean }),
|
|
431
|
+
policy: allow('public'),
|
|
432
|
+
handle({ input }) {
|
|
433
|
+
if (input.reset) deleteCookie('theme', { path: '/' });
|
|
434
|
+
else setCookie('theme', input.theme, { httpOnly: false, maxAge: 60 * 60 * 24 * 365 });
|
|
435
|
+
return { ok: true };
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
| | |
|
|
441
|
+
|---|---|
|
|
442
|
+
| defaults | `Path=/; HttpOnly; Secure; SameSite=Lax` — core's; every option is `SetCookieOptions` |
|
|
443
|
+
| two calls | two `Set-Cookie` lines, in call order. The same name on two paths is two cookies; nothing overwrites |
|
|
444
|
+
| clearing | **`deleteCookie(name, { path, domain })` is the one way**: empty value, `Max-Age=0`, the `Path`/`Domain` (and `Partitioned`) the cookie was set with. `setCookie` with `maxAge: 0`, or an `expires` at or before `ctx.now()`, is `X_COOKIE_INVALID` naming `deleteCookie` |
|
|
445
|
+
| an invalid cookie | `X_COOKIE_INVALID` (core) before anything is appended: a non-token name, `__Host-` without `Secure`, a pair over 4096 octets |
|
|
446
|
+
| off HTTP | `X_NO_REQUEST` in a job, a task or an impersonated child context; `X_NO_CONTEXT` outside any |
|
|
447
|
+
| by hand | refused: `bun run set-cookie-literals` fails the build on a `Set-Cookie` attribute spelled in a literal anywhere but `packages/core/src/cookie.ts` (`X_SET_COOKIE_HAND_BUILT`) |
|
|
448
|
+
|
|
386
449
|
## Errors
|
|
387
450
|
|
|
388
451
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/error-titles.ts"
|
|
9
|
+
],
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -25,16 +28,16 @@
|
|
|
25
28
|
"LICENSE"
|
|
26
29
|
],
|
|
27
30
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
29
32
|
},
|
|
30
33
|
"scripts": {
|
|
31
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
35
|
"test": "bun test"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/core": "25.0.0",
|
|
39
|
+
"@ultimat3/i18n": "25.0.0",
|
|
40
|
+
"@ultimat3/schema": "25.0.0",
|
|
41
|
+
"@ultimat3/time": "25.0.0"
|
|
39
42
|
}
|
|
40
43
|
}
|
package/src/app-config.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// hold this package's types, so an `http` block on `AppConfig` would be a second declaration of
|
|
5
5
|
// `HttpConfigInput` in a package that can never check it against this one.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type HttpConfigInput, refuseDeletedHttpKeys } from './config';
|
|
8
8
|
import type { RateLimitConfig } from './rate-limit';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -47,6 +47,8 @@ export type AppHttpConfig = Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'
|
|
|
47
47
|
let declared: AppHttpConfig | undefined;
|
|
48
48
|
|
|
49
49
|
export const configureHttp = (config: AppHttpConfig): void => {
|
|
50
|
+
// At the declaration, so the stack names the app's own line — not the boot that merged it.
|
|
51
|
+
refuseDeletedHttpKeys(config);
|
|
50
52
|
declared = config;
|
|
51
53
|
};
|
|
52
54
|
|