@ultimat3/http 22.15.0 → 24.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 +41 -15
- package/README.md +39 -9
- package/package.json +6 -5
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +36 -4
- package/src/context.ts +1 -1
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +8 -3
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +60 -0
- package/src/error-map-tier-1.ts +71 -0
- package/src/error-map-tier-2.ts +118 -0
- package/src/error-map-tier-3.ts +92 -0
- package/src/error-map-tier-4.ts +92 -0
- package/src/error-map.ts +27 -452
- package/src/error-page.ts +1 -2
- package/src/errors.ts +3 -3
- package/src/forwarded.ts +16 -5
- package/src/health-disclosure.ts +69 -0
- package/src/hooks.ts +12 -1
- package/src/html-render.ts +3 -17
- package/src/index.ts +7 -8
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +13 -0
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +20 -5
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +27 -18
- package/src/rate-limit-stage.ts +123 -0
- package/src/rate-limit.ts +18 -0
- package/src/request.ts +10 -3
- package/src/response.ts +8 -0
- package/src/router.ts +20 -7
- package/src/server.ts +27 -6
- package/src/stages.ts +38 -46
package/CLAUDE.md
CHANGED
|
@@ -14,7 +14,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
14
14
|
|
|
15
15
|
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`,
|
|
16
16
|
`requestTimeoutMs`, `maxInflight`, `drainTimeoutMs`, `trustedProxyHops`) → `X_CONFIG_INVALID`
|
|
17
|
-
(borrowed).
|
|
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
|
|
@@ -36,6 +37,9 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
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
|
|
|
@@ -84,26 +88,30 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
84
88
|
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`** — the entry at
|
|
85
89
|
`entries.length - hops`, never `[0]`; a short chain trusts nothing. `trustProxy` defaults to false
|
|
86
90
|
and requires `trustedProxyHops`. `x-forwarded-proto` (HSTS) and Envoy XFCC (`peer-identity.ts`) ride
|
|
87
|
-
it; `ctx.peer` is `null` unless trusted
|
|
91
|
+
it; `ctx.peer` is `null` unless trusted AND `trustClientCertHeader` is declared (appending to
|
|
92
|
+
XFF is no promise of stripping a client-sent cert header), and is never an actor.
|
|
88
93
|
- **One deadline per request** (`deadline.ts`): `requestTimeoutMs` (30 s, `0` disables), shortenable
|
|
89
94
|
by `x-request-timeout-ms`, never lengthened. `ctx.signal` is the deadline OR the caller going away
|
|
90
95
|
(`AbortSignal.any` with `Request.signal`); `expired` is the timer's alone. `Deadline.deadlineAt` is
|
|
91
96
|
published as core's `ctx.deadlineAt`, which `traceHeaders()` sends onward. Always `deadline.clear()`
|
|
92
|
-
in the `finally`.
|
|
97
|
+
in the `finally`. An ask above `MAX_TIMER_MS` is ignored. **A handler that outlives its 504 is held
|
|
98
|
+
in core's in-flight count** (`beginWork()` in `execute`) until it settles (`pipeline-inflight.test.ts`).
|
|
93
99
|
- **`admit` is the second stage and refuses before ANY work**: past `maxInflight` (1000) is
|
|
94
100
|
`X_OVERLOADED` with `retry-after`, counted by core's `inflightCount()`. **A DRAINING process
|
|
95
101
|
serves** with `connection: close`; only `lifecycleState() === 'stopped'` answers `X_DRAINING`
|
|
96
102
|
(`pipeline-hardening.test.ts`).
|
|
97
|
-
- **`csrf` sits after `auth` and before `body`**: an
|
|
103
|
+
- **`csrf` sits after `auth` and before `body`**: an unsafe request needs
|
|
98
104
|
`sec-fetch-site: same-origin`, an `Origin` equal to this app (from `ctx.https`), or one EXACTLY in
|
|
99
105
|
`cors.origins` (`originListed`, never `allowedOrigin`); else `X_CSRF_BLOCKED` (403). No
|
|
100
|
-
`mode: 'token'`.
|
|
106
|
+
`mode: 'token'`. **Anonymous is not an exemption** (login CSRF): exempt are an `Authorization`
|
|
107
|
+
header, and an anonymous request with NEITHER `Origin` nor `sec-fetch-site` (not a browser).
|
|
101
108
|
- **`meta.enforcedBy` says who evaluates `meta.policy`**: `'pipeline'` (default) decides via
|
|
102
109
|
`hooks.authorize`; `'handler'` stands the `authz` stage down.
|
|
103
110
|
- **A 403's `fix:` names the POLICY** (`route.meta.policy`), degrading to `x routes --json` for a
|
|
104
111
|
composite.
|
|
105
112
|
- **The body cap is enforced while reading** (`UltimateRequest.#read`'s counting reader, cancelling
|
|
106
|
-
past `bodyLimitBytes`); multipart goes through the same capped bytes.
|
|
113
|
+
past `bodyLimitBytes`); multipart goes through the same capped bytes. **Bytes with no
|
|
114
|
+
`content-type` are `X_BODY_INVALID`** from `bodyRaw()`, never "no body"; `bodyBytes()` reads them.
|
|
107
115
|
- **A repeated field is a LIST in all three parsers** (`collectFields`).
|
|
108
116
|
- **`matchRoute` never throws**: `router.ts`'s `decodeSegment` answers `path-invalid` →
|
|
109
117
|
`X_PATH_INVALID` (400).
|
|
@@ -120,13 +128,20 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
120
128
|
- **The `cache-headers` stage is the ONE owner of the final cache answer**: `offersSharedCache` turns a
|
|
121
129
|
shared answer for an identified request into `PRIVATE_CACHE` and gives an anonymous one
|
|
122
130
|
`SHARED_CACHE_VARY` (`accept-language, cookie`); `immutable` is left alone. `vary` is added, never
|
|
123
|
-
set (`addVary`).
|
|
131
|
+
set (`addVary`). An offer is `public`, `s-maxage`, or any `max-age` / `must-revalidate` /
|
|
132
|
+
`proxy-revalidate` without `private`/`no-store` (RFC 9111 §3).
|
|
133
|
+
- **`Set-Cookie` is appended by the `response` stage, every other context header is `set`** —
|
|
134
|
+
it is the one header that is a list of lines (`pipeline-cookies.test.ts`).
|
|
135
|
+
- **A redirect the framework writes stays on this origin**: the default-locale redirect goes
|
|
136
|
+
through `normalizePath`; `locationFor` hands over `http:`/`https:` only, else the requested path.
|
|
124
137
|
- **A `cache-control` age is delta-seconds or DROPPED** (`finiteDeltaSeconds`, total, always the
|
|
125
138
|
shorter direction; logs `http.cache_hint_not_delta_seconds`). Its boot half is `route-cache.ts`:
|
|
126
139
|
`createRouter` refuses a bad `Route.cache` with `X_CONFIG_INVALID`. Both accept the same set; zero
|
|
127
140
|
is legal. `ctx.cache` is not screened.
|
|
128
141
|
- **A handler's own `Response.status` is never rewritten** (`pipeline-handler-status.test.ts`).
|
|
129
|
-
- Health endpoints answer outside the pipeline
|
|
142
|
+
- Health endpoints answer outside the pipeline, so their body is a stranger's: `{ state, ready,
|
|
143
|
+
role }` unless the peer is in `healthDetailPeers` (default `['loopback']`; socket AND trusted
|
|
144
|
+
forwarded caller must both be listed — `health-disclosure.ts`). **Lifecycle belongs to core** (`beginWork()`,
|
|
130
145
|
`markReady()`, `drain()`, the payloads) — never a private state or in-flight counter.
|
|
131
146
|
- **`stop()` hands its two hooks back ABOVE its early return** (`packages/http/e2e/server.e2e.test.ts`
|
|
132
147
|
reads `shutdownHookCount()`).
|
|
@@ -140,7 +155,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
140
155
|
`ERROR_DOCS_URL`.** `finalize.ts`'s `lastResort` literal is pinned against `problemTypeFor('X_INTERNAL')`.
|
|
141
156
|
- **A 5xx cause is withheld unless its code opts in** (`hasPublicCause`, `problem-meta.ts`;
|
|
142
157
|
`registerProblemMeta({ CODE: { publicCause: true } })`). `toProblem(error, { dev })` defaults `dev`
|
|
143
|
-
to false. An unclassified 5xx carries nothing off the throwable.
|
|
158
|
+
to false. An unclassified 5xx carries nothing off the throwable. **A hidden cause hides the `fix`
|
|
159
|
+
too**: `callerFix`, else `x errors explain <CODE> --json` — never `facts.fix` outside dev.
|
|
144
160
|
- **The document carries the ISSUE LIST** as a top-level `issues` member: `issuesOf` is total,
|
|
145
161
|
all-or-nothing, absent (never `[]`) when none, dropped past `MAX_PROBLEM_ISSUES` (100), dropped under
|
|
146
162
|
the opacity condition; `received` forced to `''`, entries rebuilt member by member.
|
|
@@ -173,7 +189,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
173
189
|
- **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
|
|
174
190
|
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `createPipeline`) refuses `'shared'` over a
|
|
175
191
|
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `createServer({ rateLimitStore })`.
|
|
176
|
-
-
|
|
192
|
+
- **A failed `auth: 'required'` is metered in the `auth` stage** (`spendUnauthenticated`,
|
|
193
|
+
`rate-limit-stage.ts`): `defaultBucket` under `unauthenticated|ip:<address>`, not route-scoped,
|
|
194
|
+
spent only on failure — the 401 leaves before `rate-limit`. Covers the bearer mount's bad tokens.
|
|
195
|
+
Spent, it REFUSES the address before `authenticate()` (`refuseExhaustedAddress`, via the store's
|
|
196
|
+
read-only `peek` — never a take: that was an upsert per signed-in page),
|
|
197
|
+
a valid credential included; no `ctx.ip`, no gate. Signed-in callers behind that NAT wait too.
|
|
198
|
+
- **`postgresRateLimitStore({ executor })` is the shared store**, over `@ultimat3/core`'s structural
|
|
199
|
+
`PgExecutor` (re-exported here). The
|
|
177
200
|
refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
|
|
178
201
|
`purgeExpired(nowMs)` takes the caller's clock.
|
|
179
202
|
- **A bucket a route names must be registered**: `withRouteBuckets` merges `meta.rateLimitBucket`
|
|
@@ -199,11 +222,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
199
222
|
| `pipeline.ts` | the ORDER the stages run in — the framework's guarantee — and the one loop that drives a request through them |
|
|
200
223
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
201
224
|
| `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 |
|
|
225
|
+
| `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
226
|
| `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
|
|
227
|
+
| `error-map.ts` | the code → status table, closed: COMPOSES the slices and holds no row of its own |
|
|
228
|
+
| `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
229
|
| `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
|
-
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
|
|
230
|
+
| `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
231
|
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
208
232
|
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
209
233
|
| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
|
|
@@ -220,10 +244,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
220
244
|
| `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
|
|
221
245
|
| `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
|
|
222
246
|
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
223
|
-
| `csrf.ts` | the origin proof an unsafe method from a
|
|
247
|
+
| `csrf.ts` | the origin proof an unsafe method from a browser must carry, signed in or not |
|
|
224
248
|
| `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
|
|
249
|
+
| `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 `@ultimat3/core`'s `readCookie`, which `index.ts` re-exports |
|
|
226
250
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
251
|
+
| `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 |
|
|
252
|
+
| `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
253
|
| `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
|
|
228
254
|
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
229
255
|
|
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 |
|
|
@@ -95,13 +95,23 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
95
95
|
| 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 |
|
|
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
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 `createServer({ …, 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,6 +127,11 @@ 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
|
createServer,
|
|
@@ -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`.
|
|
@@ -259,9 +274,15 @@ const handle = createServer({
|
|
|
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
|
|
@@ -303,6 +324,9 @@ exactly three things. Apps declare it through `defineApi({ http: { mounts } })`
|
|
|
303
324
|
|
|
304
325
|
`RouteMeta.authenticate` is the mechanism: a route that states one is reached only through it.
|
|
305
326
|
|
|
327
|
+
For API keys, `resolveToken` is `@ultimat3/auth`'s `apiKeyResolver(() => store)` — a live key is
|
|
328
|
+
`{ actor, scopes }`, every wrong one is the same `null`, and a store fault stays a throw.
|
|
329
|
+
|
|
306
330
|
## Inbound webhooks
|
|
307
331
|
|
|
308
332
|
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
|
|
@@ -439,3 +463,9 @@ Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ult
|
|
|
439
463
|
tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
|
|
440
464
|
`ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
|
|
441
465
|
plugin API: `Middleware` wraps a handler, the pipeline is everything else.
|
|
466
|
+
|
|
467
|
+
`hooks.explainMiss(method, pathname)` is the same kind of seam for a request the router matched
|
|
468
|
+
nothing for: the error it returns is thrown in place of `X_ROUTE_NOT_FOUND`, `undefined` keeps the
|
|
469
|
+
plain miss. Synchronous, given only the request line, never asked about a matched route or a 405.
|
|
470
|
+
`@ultimat3/action` answers it for a path that is one of its actions under a `pathStyle` this server
|
|
471
|
+
does not serve (`X_CONTRACT_DRIFT`, 404).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,9 +32,9 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "24.0.0",
|
|
36
|
+
"@ultimat3/i18n": "24.0.0",
|
|
37
|
+
"@ultimat3/schema": "24.0.0",
|
|
38
|
+
"@ultimat3/time": "24.0.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/cache-policy.ts
CHANGED
|
@@ -16,14 +16,22 @@ export const PRIVATE_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
|
|
|
16
16
|
// for every signed-in user.
|
|
17
17
|
const OFFERS_SHARED = /(?:^|,)\s*(?:public\b|s-maxage=)/i;
|
|
18
18
|
const IMMUTABLE = /(?:^|,)\s*immutable\b/i;
|
|
19
|
+
// RFC 9111 §3: a shared cache may store a response to a request without `Authorization` — which
|
|
20
|
+
// is every cookie session — as soon as it carries explicit freshness or a revalidation rule. So
|
|
21
|
+
// `max-age=3600` alone is an offer too, unless the same header withholds it.
|
|
22
|
+
const STATES_FRESHNESS = /(?:^|,)\s*(?:max-age=|must-revalidate\b|proxy-revalidate\b)/i;
|
|
23
|
+
const WITHHOLDS = /(?:^|,)\s*(?:private\b|no-store\b)/i;
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Whether a `cache-control` a HANDLER wrote offers the response to a shared cache. A render mode
|
|
22
27
|
* states the MODE's intent — `ssr` offers an ungated page to a CDN for 30 seconds — and the actor
|
|
23
28
|
* is the half it cannot see, so the stage reviews the declaration rather than deferring to it.
|
|
24
29
|
*/
|
|
25
|
-
export const offersSharedCache = (declared: string): boolean =>
|
|
26
|
-
|
|
30
|
+
export const offersSharedCache = (declared: string): boolean => {
|
|
31
|
+
if (IMMUTABLE.test(declared)) return false;
|
|
32
|
+
if (OFFERS_SHARED.test(declared)) return true;
|
|
33
|
+
return STATES_FRESHNESS.test(declared) && !WITHHOLDS.test(declared);
|
|
34
|
+
};
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* Authenticated responses are never shared-cacheable; that default is not overridable.
|
package/src/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
|
7
7
|
import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
|
|
8
8
|
import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
|
|
9
9
|
import { httpCountInvalid, trustProxyUnset } from './errors';
|
|
10
|
+
import { assertHealthDetailPeers, DEFAULT_HEALTH_DETAIL_PEERS } from './health-disclosure';
|
|
10
11
|
import {
|
|
11
12
|
DEFAULT_LOCALE_CONFIG,
|
|
12
13
|
DEFAULT_TZ_CONFIG,
|
|
@@ -45,6 +46,20 @@ export interface HttpConfig {
|
|
|
45
46
|
* `0` when nothing is trusted.
|
|
46
47
|
*/
|
|
47
48
|
readonly trustedProxyHops: number;
|
|
49
|
+
/**
|
|
50
|
+
* Read Envoy's `x-forwarded-client-cert` into `ctx.peer`. Its OWN declaration, `false` until an
|
|
51
|
+
* app makes it: `trustProxy` says the proxy appends to `x-forwarded-for`, which is no promise
|
|
52
|
+
* that it strips or overwrites a certificate header the client sent — and this one names an
|
|
53
|
+
* identity. Read at `trustedProxyHops`, so it does nothing without `trustProxy`.
|
|
54
|
+
*/
|
|
55
|
+
readonly trustClientCertHeader: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Who `/healthz` and `/readyz` tell more than the verdict: each entry an address class
|
|
58
|
+
* (`'loopback'`, `'private'`, …) or one exact address. Everyone else gets `state`, `ready` and
|
|
59
|
+
* `role` — the endpoints answer outside the pipeline, so the build id, the in-flight count and
|
|
60
|
+
* the readiness check names were otherwise any stranger's to read. `[]` tells nobody.
|
|
61
|
+
*/
|
|
62
|
+
readonly healthDetailPeers: readonly string[];
|
|
48
63
|
readonly bodyLimitBytes: number;
|
|
49
64
|
/**
|
|
50
65
|
* How long one request may run before it is aborted and answered `X_TIMEOUT` (504). `0`
|
|
@@ -89,6 +104,8 @@ export interface HttpConfigInput {
|
|
|
89
104
|
readonly signInPath?: string | null;
|
|
90
105
|
readonly trustProxy?: boolean;
|
|
91
106
|
readonly trustedProxyHops?: number;
|
|
107
|
+
readonly trustClientCertHeader?: boolean;
|
|
108
|
+
readonly healthDetailPeers?: readonly string[];
|
|
92
109
|
readonly bodyLimitBytes?: number;
|
|
93
110
|
readonly requestTimeoutMs?: number;
|
|
94
111
|
readonly maxInflight?: number;
|
|
@@ -170,6 +187,13 @@ const assertFiniteCount = (
|
|
|
170
187
|
|
|
171
188
|
const MAX_PORT = 65_535;
|
|
172
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The longest delay a timer holds: a signed 32-bit count of milliseconds, about 24.8 days. Past
|
|
192
|
+
* it `setTimeout` arms ~1 ms, so a "longer" request budget timed every request out at once.
|
|
193
|
+
* `requestTimeoutMs` is screened against it and `deadline.ts` reads the inbound header by it.
|
|
194
|
+
*/
|
|
195
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
196
|
+
|
|
173
197
|
/**
|
|
174
198
|
* Nobody has 64 proxies in front of one process; a bigger number is a typo, not a topology.
|
|
175
199
|
*
|
|
@@ -241,14 +265,22 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
241
265
|
'a whole port number from 0 to 65535, where 0 asks the OS for a free one',
|
|
242
266
|
'port: 3000',
|
|
243
267
|
),
|
|
244
|
-
|
|
268
|
+
// Never `HOSTNAME`: Docker sets it to the container id, which is not an address to bind. The
|
|
269
|
+
// boot passes what `HOST` says (`@ultimat3/cli`'s `hostnameFromEnv`); an embedder passes its own.
|
|
270
|
+
hostname: input.hostname ?? '0.0.0.0',
|
|
245
271
|
basePath: input.basePath ?? '/',
|
|
246
|
-
|
|
272
|
+
// `undefined` falls back to the environment; an explicit `null` is the declaration that
|
|
273
|
+
// switches skew detection off, and `??` would have read it as unset.
|
|
274
|
+
buildId: input.buildId === undefined ? (env('BUILD_ID') ?? null) : input.buildId,
|
|
247
275
|
buildIdHeader: input.buildIdHeader ?? 'x-ultimate-build',
|
|
248
276
|
dev,
|
|
249
277
|
signInPath: input.signInPath ?? null,
|
|
250
278
|
trustProxy,
|
|
251
279
|
trustedProxyHops,
|
|
280
|
+
trustClientCertHeader: input.trustClientCertHeader === true,
|
|
281
|
+
healthDetailPeers: assertHealthDetailPeers(
|
|
282
|
+
input.healthDetailPeers === undefined ? DEFAULT_HEALTH_DETAIL_PEERS : input.healthDetailPeers,
|
|
283
|
+
),
|
|
252
284
|
bodyLimitBytes: assertFiniteCount(
|
|
253
285
|
'bodyLimitBytes',
|
|
254
286
|
input.bodyLimitBytes ?? 1_048_576,
|
|
@@ -261,8 +293,8 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
261
293
|
requestTimeoutMs: assertFiniteCount(
|
|
262
294
|
'requestTimeoutMs',
|
|
263
295
|
input.requestTimeoutMs ?? 30_000,
|
|
264
|
-
|
|
265
|
-
|
|
296
|
+
MAX_TIMER_MS,
|
|
297
|
+
`a whole number of milliseconds up to ${MAX_TIMER_MS} (the longest a timer holds), where 0 means no deadline`,
|
|
266
298
|
'requestTimeoutMs: 30_000',
|
|
267
299
|
),
|
|
268
300
|
maxInflight: assertFiniteCount(
|
package/src/context.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type Logger,
|
|
12
12
|
traceId as newTraceId,
|
|
13
13
|
type Role,
|
|
14
|
+
readCookie,
|
|
14
15
|
type ServiceBag,
|
|
15
16
|
systemClock,
|
|
16
17
|
useContext,
|
|
@@ -21,7 +22,6 @@ import { timeConfig } from '@ultimat3/time';
|
|
|
21
22
|
import type { HttpConfig } from './config';
|
|
22
23
|
import { noRequest } from './errors';
|
|
23
24
|
import type { AuthzDecision } from './hooks';
|
|
24
|
-
import { readCookie } from './locale';
|
|
25
25
|
import type { PeerIdentity } from './peer-identity';
|
|
26
26
|
import type { RateLimitDecision } from './rate-limit';
|
|
27
27
|
import { bindRequestServices } from './request-services';
|
package/src/csrf.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Whether an unsafe request
|
|
2
|
-
// it. CORS does not answer this: `application/x-www-form-urlencoded` is a CORS-simple content
|
|
1
|
+
// Whether an unsafe request a BROWSER sent — with the session cookie, or to obtain one — came
|
|
2
|
+
// from somewhere allowed to make it. CORS does not answer this: `application/x-www-form-urlencoded` is a CORS-simple content
|
|
3
3
|
// type, so `<form method="post">` on evil.test is SENT and EXECUTED with the session cookie
|
|
4
4
|
// attached — `cors.origins: []` only stops the attacker reading the reply, long after the refund
|
|
5
5
|
// went through. `setRedirect` exists so those form posts work without JS, which makes them a
|
|
@@ -34,7 +34,11 @@ export interface CsrfCheckInput {
|
|
|
34
34
|
readonly secFetchSite: string | null;
|
|
35
35
|
/** A bearer token is not ambient: a cross-site page cannot make the browser attach one. */
|
|
36
36
|
readonly hasAuthorizationHeader: boolean;
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* No session resolved. NOT an exemption: a sign-in form is forgeable too (login CSRF — the
|
|
39
|
+
* visitor ends up in the attacker's account), so an anonymous write that carries browser
|
|
40
|
+
* evidence is judged exactly as a credentialed one. See `checkCsrf` for the one thing it buys.
|
|
41
|
+
*/
|
|
38
42
|
readonly anonymous: boolean;
|
|
39
43
|
readonly cors: CorsConfig;
|
|
40
44
|
readonly config: CsrfConfig;
|
|
@@ -50,12 +54,17 @@ export type CsrfVerdict =
|
|
|
50
54
|
* `Origin` second, so an app that lists a sibling origin in `cors.origins` keeps working. A
|
|
51
55
|
* request with neither — a non-browser client with a cookie, or a browser too old to send
|
|
52
56
|
* either — is refused: "we could not tell" is the case this exists for.
|
|
57
|
+
*
|
|
58
|
+
* An ANONYMOUS request carrying neither is the one case let through: there is no ambient
|
|
59
|
+
* credential to ride and no browser evidence to judge, which is an inbound webhook or a
|
|
60
|
+
* server-to-server call, never a page a hostile site can drive — every browser that can submit a
|
|
61
|
+
* cross-site form sends `Origin` on it. Either header present, and it is judged like any other.
|
|
53
62
|
*/
|
|
54
63
|
export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
|
|
55
64
|
if (input.config.mode === 'off') return { ok: true };
|
|
56
65
|
if (SAFE_METHODS.has(input.method)) return { ok: true };
|
|
57
|
-
if (input.anonymous) return { ok: true };
|
|
58
66
|
if (input.hasAuthorizationHeader) return { ok: true };
|
|
67
|
+
if (input.anonymous && input.origin === null && input.secFetchSite === null) return { ok: true };
|
|
59
68
|
|
|
60
69
|
// The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
|
|
61
70
|
// come from this app?" on every surface an ambient credential reaches.
|
|
@@ -79,8 +88,8 @@ export const selfOrigin = (url: URL, https: boolean): string =>
|
|
|
79
88
|
`${https ? 'https' : 'http'}://${url.host}`;
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
|
-
* A write
|
|
83
|
-
* this app. Never a 401:
|
|
91
|
+
* A write a browser sent — with its ambient credential, or anonymously to obtain one — that could
|
|
92
|
+
* not be shown to come from this app. Never a 401: who is calling was never the question.
|
|
84
93
|
*
|
|
85
94
|
* From a LOOPBACK `ip` the caller is `curl` against `x dev`: it sends neither
|
|
86
95
|
* header, and the remedies below name a token or a config change dev does not need. The header a
|
|
@@ -95,11 +104,11 @@ export const csrfBlocked = (
|
|
|
95
104
|
ip !== null && classifyAddress(ip) === 'loopback'
|
|
96
105
|
? new HttpError({
|
|
97
106
|
code: 'X_CSRF_BLOCKED',
|
|
98
|
-
cause: `${pathname} refused a
|
|
107
|
+
cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
|
|
99
108
|
fix: "curl -H 'sec-fetch-site: same-origin' -X POST http://localhost:3000/the-path-above # a local client proves same-origin the way a browser does; the session cookie still decides who is calling",
|
|
100
109
|
})
|
|
101
110
|
: new HttpError({
|
|
102
111
|
code: 'X_CSRF_BLOCKED',
|
|
103
|
-
cause: `${pathname} refused a
|
|
112
|
+
cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
|
|
104
113
|
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
|
|
105
114
|
});
|
package/src/deadline.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// never finish.
|
|
6
6
|
|
|
7
7
|
import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
|
|
8
|
-
import type
|
|
8
|
+
import { type HttpConfig, MAX_TIMER_MS } from './config';
|
|
9
9
|
import { requestTimedOut } from './errors';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -46,13 +46,18 @@ const NO_DEADLINE: Deadline = {
|
|
|
46
46
|
clear: () => undefined,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The configured budget, or the caller's if theirs is shorter. `0` means "no deadline".
|
|
51
|
+
*
|
|
52
|
+
* An ask above `MAX_TIMER_MS` is no ask: no timer can hold it — the runtime arms such a delay as
|
|
53
|
+
* ~1 ms, so "a very long deadline" was `X_TIMEOUT` within a tick — and a caller may only shorten.
|
|
54
|
+
*/
|
|
50
55
|
export const resolveTimeoutMs = (headers: Headers, config: HttpConfig): number => {
|
|
51
56
|
const configured = config.requestTimeoutMs;
|
|
52
57
|
const raw = headers.get(REQUEST_TIMEOUT_HEADER);
|
|
53
58
|
if (raw === null) return configured;
|
|
54
59
|
const asked = Number.parseInt(raw, 10);
|
|
55
|
-
if (!Number.isFinite(asked) || asked < 1) return configured;
|
|
60
|
+
if (!Number.isFinite(asked) || asked < 1 || asked > MAX_TIMER_MS) return configured;
|
|
56
61
|
return configured > 0 ? Math.min(configured, asked) : asked;
|
|
57
62
|
};
|
|
58
63
|
|
package/src/error-facts.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
ERROR_DOCS_URL,
|
|
7
7
|
FRAMEWORK_CODE,
|
|
8
|
+
hasPublicCause,
|
|
8
9
|
isUltimateError,
|
|
9
10
|
renderCauseValue,
|
|
10
11
|
singleLine,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
14
15
|
import { declaredStatusFor, statusFor } from './error-status';
|
|
15
16
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
16
|
-
import {
|
|
17
|
+
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
17
18
|
|
|
18
19
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
19
20
|
export interface ErrorFacts {
|
|
@@ -270,6 +271,14 @@ export interface ProblemDocument {
|
|
|
270
271
|
readonly meta?: ProblemMeta | undefined;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The fix a hidden document carries: the code's own registry entry, nothing off the throwable.
|
|
276
|
+
* `FRAMEWORK_CODE` gates it for `factsOf`'s reason — this is a command a reader pastes, and `code`
|
|
277
|
+
* is a string field off a value this package did not build.
|
|
278
|
+
*/
|
|
279
|
+
const explainFix = (code: string): string =>
|
|
280
|
+
FRAMEWORK_CODE.test(code) ? `x errors explain ${code} --json` : 'x errors list --json';
|
|
281
|
+
|
|
273
282
|
/** The title a caller gets for a failure the framework cannot name. */
|
|
274
283
|
const INTERNAL_TITLE = 'unhandled server error';
|
|
275
284
|
|
|
@@ -331,7 +340,14 @@ export const toProblem = (
|
|
|
331
340
|
// The CALLER's fix outside dev: a production client cannot run `x policy explain`, and the
|
|
332
341
|
// developer's line stays in the log this request wrote. Dev keeps the developer's, because the
|
|
333
342
|
// reader of a dev response is the developer.
|
|
334
|
-
fix
|
|
343
|
+
// HIDDEN, the developer's fix is withheld with the cause it was built beside: a driver or repo
|
|
344
|
+
// error writes its fix from the statement, the row or the path it failed on, so serving it
|
|
345
|
+
// under a blanked cause put back what the blanking took out. A declared `callerFix` is
|
|
346
|
+
// authored FOR the caller and still goes; otherwise the line names the code and nothing else.
|
|
347
|
+
fix:
|
|
348
|
+
meta.dev === true
|
|
349
|
+
? facts.fix
|
|
350
|
+
: (facts.callerFix ?? (hidden ? explainFix(facts.code) : facts.fix)),
|
|
335
351
|
docs: facts.docs,
|
|
336
352
|
requestId: meta.requestId,
|
|
337
353
|
...(issues === undefined ? {} : { issues }),
|