@ultimat3/http 29.4.0 → 30.1.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 +26 -51
- package/README.md +119 -6
- package/package.json +15 -5
- package/src/bearer-mount.ts +67 -12
- package/src/cache-policy.ts +8 -0
- package/src/compress-read.ts +115 -0
- package/src/compress.ts +173 -0
- package/src/config.ts +1 -7
- package/src/context.ts +50 -0
- package/src/cors.ts +43 -4
- package/src/csrf.ts +41 -8
- package/src/deadline.ts +2 -2
- package/src/error-facts.ts +26 -1
- package/src/error-map-http.ts +3 -0
- package/src/error-map-tier-0.ts +26 -0
- package/src/error-map-tier-1.ts +22 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +24 -0
- package/src/error-map-tier-4.ts +33 -1
- package/src/error-page.ts +6 -7
- package/src/error-status.ts +26 -17
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +27 -12
- package/src/finalize.ts +1 -0
- package/src/index.ts +17 -2
- package/src/locale.ts +4 -1
- package/src/object-response.ts +134 -0
- package/src/overlay.ts +13 -2
- package/src/pipeline.ts +6 -1
- package/src/preferences.ts +4 -0
- package/src/problem-meta-framework.ts +21 -0
- package/src/problem-meta.ts +23 -15
- package/src/rate-limit-degrade.ts +10 -2
- package/src/rate-limit-postgres.ts +22 -9
- package/src/rate-limit-store-deadline.ts +60 -0
- package/src/response.ts +29 -5
- package/src/router.ts +9 -0
- package/src/server-websocket.ts +35 -0
- package/src/server.ts +8 -4
- package/src/stages.ts +18 -5
- package/src/stored-object-headers.ts +43 -0
- package/src/webhook-hmac.ts +73 -0
- package/src/webhook-verify.ts +26 -5
- package/src/websocket-mounts.ts +104 -0
package/CLAUDE.md
CHANGED
|
@@ -12,14 +12,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
12
12
|
|
|
13
13
|
## Rules — configuration
|
|
14
14
|
|
|
15
|
-
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`,
|
|
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`,
|
|
19
|
-
`assertFiniteBodyLimit`) so `bun run finite-bounds` sees them; `webhook-verify.ts` and
|
|
20
|
-
`rate-limit.ts` screen their own. **The floor is per option**: `requestTimeoutMs: 0` and
|
|
21
|
-
`maxInflight: 0` are "off"; `trustedProxyHops` floors at 1. `resolveTrustedProxyHops` owns both
|
|
22
|
-
refusals (`X_TRUST_PROXY_UNSET`, `X_CONFIG_INVALID`), with no `?? 0` behind it.
|
|
15
|
+
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`, `requestTimeoutMs`, `maxInflight`, `trustedProxyHops`) → `X_CONFIG_INVALID` (borrowed). Full rule: `docs/history/http.md`.
|
|
23
16
|
- **`MAX_PROXY_HOPS` is exported** — `@ultimat3/cli`'s `trustedHopsFromEnv` imports it; one ceiling.
|
|
24
17
|
- Route `meta.auth` is required. Never default a route to public.
|
|
25
18
|
- **An app declares its half of `HttpConfig` through `configureHttp()`**, and the boot lays its own
|
|
@@ -31,8 +24,9 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
31
24
|
- **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role). Key
|
|
32
25
|
`drainTimeoutMs` (deleted 25.0.0) is `X_CONFIG_INVALID` via `refuseDeletedHttpKeys`.
|
|
33
26
|
**`ServerOptions.drain`** passes only `readinessGraceMs`, and only when declared.
|
|
34
|
-
- **`cors.origins: ['*']` with `credentials: true` is `X_CORS_CONFIG_INVALID`.** A refused origin
|
|
27
|
+
- **`cors.origins: ['*']` with `credentials: true`, or `'null'` in the list, is `X_CORS_CONFIG_INVALID`.** A refused origin
|
|
35
28
|
still gets `vary: origin`.
|
|
29
|
+
- **`DEFAULT_CORS` is what `clientTransport` sends and reads**, off core's constants; `cors.test.ts` observes the transport's real traffic, so a new header fails it. `date` alone stays hidden: this server writes delta-seconds. An app's list replaces the default whole.
|
|
36
30
|
- **A `security.csp.extend` entry must emit only the directive it names**
|
|
37
31
|
(`X_CSP_DIRECTIVE_INVALID`); `buildCsp` builds through a `Map`.
|
|
38
32
|
- **The security headers are built once per `(SecurityConfig, https)`** (`responseSecurityHeaders`, a
|
|
@@ -52,6 +46,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
52
46
|
- **A request's registered services are LAZY and bound to the authenticated actor**
|
|
53
47
|
(`request-services.ts`, `installServices: false` + `bindRequestServices`): built on first read,
|
|
54
48
|
rebuilt only if actor, locale or tz changed. `request-services.test.ts`.
|
|
49
|
+
- **A shared document renders in `withAnonymousRequest(ctx, url, fn)`**. Full rule: `docs/history/http.md`.
|
|
55
50
|
- **A member's saved locale and zone apply, re-resolved after `auth`**
|
|
56
51
|
(`resolvePreferences`; cookie beats a saved locale, a saved zone beats the cookie).
|
|
57
52
|
`member-preferences.test.ts`.
|
|
@@ -64,17 +59,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
64
59
|
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A route may
|
|
65
60
|
REPLACE it with `meta.authenticate` (never run beside it): `bearerMount` sets it so a session
|
|
66
61
|
cookie authenticates nothing on `/v1/*`.
|
|
67
|
-
- **`bearerMount` re-serves existing `Route`s, never re-projects them** (`bearer-mount.ts`):
|
|
68
|
-
handler, policy, idempotency; adds the credential (bearer only, `WWW-Authenticate` on 401), the
|
|
69
|
-
cut (outside the token's scopes = `X_ROUTE_NOT_FOUND`, the MCP rule), and a per-token bucket
|
|
70
|
-
keyed by a SHA-256 of the token. Bad prefix / unknown name / double claim:
|
|
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.
|
|
62
|
+
- **`bearerMount` re-serves existing `Route`s, never re-projects them** (`bearer-mount.ts`): the cut is decided in the `auth` stage, outside the scopes is `X_ROUTE_NOT_FOUND`, the prefix is opaque. The token allowance is spent once, in the authenticator, for every resolved token in scope or not (a 422 or 403 before the handler pays). A declared `rateLimit` REPLACES `default` on the mount's routes (`rateLimitedBy: 'handler'`); a route's own `meta.rateLimit` and the tenant allowance stay. A successor `Link` is rewritten in a `finally` to the mounted path when in the cut under a scope the token holds, else removed (`bearer-mount-wire.test.ts`). Full rule: `docs/history/http.md`.
|
|
78
63
|
- **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
|
|
79
64
|
- **A dev 4xx asks `hooks.errorPage` first; a 5xx is always the overlay** (#492): the app's file
|
|
80
65
|
when it has one, else the overlay. Production asks for every status (`stages-error-page.test.ts`).
|
|
@@ -93,20 +78,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
93
78
|
the boot's `csp.extend` of that origin exactly. `security.hsts` merges key by key.
|
|
94
79
|
- **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
|
|
95
80
|
`parseTraceparent`). `x-request-id` is gated on `trustProxy`; `traceparent` deliberately is not.
|
|
96
|
-
- **Every proxy-supplied header goes through `forwardedElement(header, hops)
|
|
97
|
-
|
|
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
|
|
101
|
-
and requires `trustedProxyHops`. `x-forwarded-proto` (HSTS) and Envoy XFCC (`peer-identity.ts`) ride
|
|
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.
|
|
104
|
-
- **One deadline per request** (`deadline.ts`): `requestTimeoutMs` (30 s, `0` off; Bun's
|
|
105
|
-
`idleTimeout` derives from it, `idle-timeout.ts`), shortened by `x-request-timeout-ms`, never
|
|
106
|
-
lengthened. `ctx.signal` is the deadline OR the caller going away (`AbortSignal.any` with
|
|
107
|
-
`Request.signal`); `expired` is the timer's alone. `deadlineAt` is published as core's
|
|
108
|
-
`ctx.deadlineAt` (`traceHeaders()` sends it on). `deadline.clear()` in the `finally`. **A handler
|
|
109
|
-
outliving its 504 is held in core's in-flight count** (`beginWork()`, `pipeline-inflight.test.ts`).
|
|
81
|
+
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`**, the entry at `entries.length - hops`, never `[0]`, except `x-forwarded-proto` (`forwardedProto`). `ctx.peer` is `null` unless trusted AND `trustClientCertHeader` is declared, and is never an actor. Full rule: `docs/history/http.md`.
|
|
82
|
+
- **One deadline per request** (`deadline.ts`): `requestTimeoutMs` (30 s, `0` off; Bun's `idleTimeout` derives from it, `idle-timeout.ts`), shortened by `x-request-timeout-ms`, never lengthened. Full rule: `docs/history/http.md`.
|
|
110
83
|
- **`admit` is the second stage and refuses before ANY work**: past `maxInflight` (1000) is
|
|
111
84
|
`X_OVERLOADED` with `retry-after`, counted by core's `inflightCount()`. **A DRAINING process
|
|
112
85
|
serves** with `connection: close`; only `lifecycleState() === 'stopped'` answers `X_DRAINING`
|
|
@@ -114,8 +87,10 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
114
87
|
- **`csrf` sits after `auth` and before `body`**: an unsafe request needs
|
|
115
88
|
`sec-fetch-site: same-origin`, an `Origin` equal to this app (from `ctx.https`), or one EXACTLY in
|
|
116
89
|
`cors.origins` (`originListed`, never `allowedOrigin`); else `X_CSRF_BLOCKED` (403). No
|
|
117
|
-
`mode: 'token'`. **Anonymous is not an exemption** (login CSRF): exempt are
|
|
118
|
-
|
|
90
|
+
`mode: 'token'`. **Anonymous is not an exemption** (login CSRF): exempt are `Authorization:
|
|
91
|
+
Bearer` (cached Basic is ambient), and an anonymous request with NEITHER `Origin` nor `sec-fetch-site` (not a browser).
|
|
92
|
+
**`meta.csrf: 'cookieless'`** (`/oauth/*`) exempts an ANONYMOUS caller only; `httpRouter`
|
|
93
|
+
refuses it without `meta.authenticate` (`assertRouteCsrf`).
|
|
119
94
|
- **`meta.enforcedBy` says who evaluates `meta.policy`**: `'pipeline'` (default) decides via
|
|
120
95
|
`hooks.authorize`; `'handler'` stands the `authz` stage down.
|
|
121
96
|
- **A 403's `fix:` names the POLICY** (`route.meta.policy`), degrading to `x routes --json` for a
|
|
@@ -157,11 +132,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
157
132
|
`markReady()`, `drain()`, the payloads) — never a private state or in-flight counter.
|
|
158
133
|
- **`stop()` hands its two hooks back ABOVE its early return** (`packages/http/e2e/server.e2e.test.ts`
|
|
159
134
|
reads `shutdownHookCount()`).
|
|
135
|
+
- **`compress` is the last stage and has no option** (`compress.ts`, its counted read `compress-read.ts`). A signed-in answer is coded only when `immutable` (`compressResponse(..., { anonymous })`, declared, never defaulted). The ceiling is 256 KiB, measured. A response that fails the rule is the SAME object with its body unread. `cache-control` with `private`, `no-store`, `no-cache` or `no-transform` is identity (BREACH); `vary: accept-encoding` goes on every answer past the type and cache conditions, coded or not. `content-length` is the bound: `html`, `textResponse`, `jsonResponse` declare it, `stream` never. A read that overruns, fails or passes `ctx.signal`, and a `gzipSync` throw, log `http.compress_failed` and answer identity, whole: never a request failure.
|
|
160
136
|
|
|
161
137
|
## Rules — errors and the problem document
|
|
162
138
|
|
|
163
|
-
- Statuses live in `error-map.ts` only; the framework table is closed
|
|
164
|
-
|
|
139
|
+
- Statuses live in `error-map.ts` only; the framework table is closed. The app's half lives in core
|
|
140
|
+
(`error-status-declared.ts`, `@ultimat3/core/internal/error-status`): an app's own code declares
|
|
141
|
+
its status with core's `defineError`; `registerErrorStatus()` keeps its refusals (a framework code)
|
|
142
|
+
and writes there, for codes an app re-raises but does not declare. No projection of the app's half.
|
|
165
143
|
**`error-facts.ts` renders the throwable**; the seam is `declaredStatusFor(code)`, imports one way.
|
|
166
144
|
- **A problem's `type` is `problemTypeFor(code)` = `urn:ultimate:error:<CODE>`; its `docs` is core's
|
|
167
145
|
`ERROR_DOCS_URL`.** `finalize.ts`'s `lastResort` literal is pinned against `problemTypeFor('X_INTERNAL')`.
|
|
@@ -178,13 +156,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
178
156
|
- **A rejected value is a log FIELD, never the message** — the `error-map` line's message is the code alone.
|
|
179
157
|
- **A rejected BODY names only what the framework chose**: `bodyInvalid`'s `issues` are a fixed
|
|
180
158
|
vocabulary; what the caller sent rides in `meta`; the parser's message goes through `renderThrowable`.
|
|
181
|
-
- **A browser failing `auth: 'required'` is redirected; an agent gets the problem document**
|
|
182
|
-
(`auth-redirect.ts`, in `error-map` before the overlay). `config.signInPath` is `null` until named.
|
|
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).
|
|
159
|
+
- **A browser failing `auth: 'required'` is redirected; an agent gets the problem document** (`auth-redirect.ts`, in `error-map` before the overlay). Full rule: `docs/history/http.md`.
|
|
188
160
|
- **A pathname in a `fix:` goes through `renderFixShellArg`** (`routeNotFound`). **A `code` is gated by
|
|
189
161
|
core's `FRAMEWORK_CODE`** before `x errors explain <code>`, else `x errors list --json`. **A supplied
|
|
190
162
|
`fix:` is taken only from a branded error** (`isUltimateError`).
|
|
@@ -192,6 +164,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
192
164
|
`X_UNAUTHENTICATED`, …); `factsOf` reads a borrowed code's title off the error.
|
|
193
165
|
- Never throw a bare `Error` — use a factory from `errors.ts`. No `any`. Validation goes through
|
|
194
166
|
Standard Schema (`validate.ts`).
|
|
167
|
+
- **Every document carries `retry` at its root** (core's `errorRetry`, through the total `retryOf`); a hidden 5xx answers the class and no cause. `finalize.ts`'s `lastResort` literal is pinned against `toProblem`'s answer for `X_INTERNAL`.
|
|
195
168
|
|
|
196
169
|
## Rules — rate limiting
|
|
197
170
|
|
|
@@ -204,12 +177,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
204
177
|
- **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
|
|
205
178
|
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `httpPipeline`) refuses `'shared'` over a
|
|
206
179
|
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `httpServer({ rateLimitStore })`.
|
|
207
|
-
- **A failed `auth: 'required'` is metered in the `auth` stage
|
|
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.
|
|
180
|
+
- **A failed `auth: 'required'` is metered in the `auth` stage**. Full rule: `docs/history/http.md`.
|
|
213
181
|
- **`postgresRateLimitStore({ executor })` is the shared store**, over `@ultimat3/core`'s structural
|
|
214
182
|
`PgExecutor` (re-exported here). The
|
|
215
183
|
refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
|
|
@@ -227,6 +195,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
227
195
|
digits-only; `:` is refused in id and topic; the comparison is `timingSafeEqual`
|
|
228
196
|
(`bun run secret-compare`). **An empty `secret` is `X_CONFIG_INVALID`.**
|
|
229
197
|
**The FORMAT is core's** (`packages/core/src/webhook-signature.ts`) — never re-declared here.
|
|
198
|
+
- **`verifyWebhookSignature` and `verifyBodyHmac` share ONE secret screen** (`assertWebhookSecrets`)
|
|
199
|
+
and one pair of refusals (`webhookSecretMissing`, `webhookSignatureInvalid`), each naming its caller.
|
|
230
200
|
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in `e2e/` (`bun test
|
|
231
201
|
packages/http/e2e`), sealed; `start()` calls core's `markListening()`. Never unseal.
|
|
232
202
|
|
|
@@ -249,6 +219,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
249
219
|
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `ctxOf`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
|
|
250
220
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
251
221
|
| `set-cookie.ts` | `setCookie`/`deleteCookie`: one line each onto `ctx.headers` |
|
|
222
|
+
| `object-response.ts` | `objectResponse`, the one answer for an object held elsewhere, structural (no storage import); `notModified`, the one `if-none-match` answer |
|
|
223
|
+
| `stored-object-headers.ts` | inline only for raster images, audio, video; else `attachment` + `sandbox`. Private to `objectResponse` |
|
|
252
224
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
253
225
|
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
|
|
254
226
|
| `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 |
|
|
@@ -263,6 +235,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
263
235
|
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
264
236
|
| `csrf.ts` | the origin proof an unsafe method from a browser must carry, signed in or not |
|
|
265
237
|
| `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 |
|
|
238
|
+
| `webhook-hmac.ts` | `verifyBodyHmac`: a vendor's HMAC-SHA256 over named bytes, UTF-8 secret, rotation list, constant-time; answers `{ bytes, text }` |
|
|
266
239
|
| `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 |
|
|
267
240
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
268
241
|
| `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 |
|
|
@@ -270,6 +243,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
270
243
|
| `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
|
|
271
244
|
| `request-facts.ts` | `RequestFacts`, any token resolver's frozen 2nd arg; address = `ctx.ip` |
|
|
272
245
|
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
246
|
+
| `compress.ts` | the `compress` stage's rule: which answers leave gzipped |
|
|
247
|
+
| `compress-read.ts` | the one body read that rule does: counted, deadline-bound, replayable |
|
|
273
248
|
|
|
274
249
|
## Commands
|
|
275
250
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@ A thin, **owned** layer over `Bun.serve`. Not a framework-agnostic HTTP kit —
|
|
|
4
4
|
lifecycle belongs to us so ALS context, tracing, locale/tz and authz are impossible
|
|
5
5
|
to skip.
|
|
6
6
|
|
|
7
|
+
`bun add @ultimat3/http` · guide: [Routes And Render Modes](https://github.com/developerz-ai/ultimate/wiki/Routes-And-Render-Modes)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { configureHttp } from '@ultimat3/http';
|
|
11
|
+
|
|
12
|
+
configureHttp({ bodyLimitBytes: 8 * 1024 * 1024, requestTimeoutMs: 300_000 });
|
|
13
|
+
```
|
|
14
|
+
|
|
7
15
|
## What it owns
|
|
8
16
|
|
|
9
17
|
| Concern | Module |
|
|
@@ -49,6 +57,11 @@ above could be set from an app at all — `cors.origins` was `[]` in every deplo
|
|
|
49
57
|
every cross-origin browser call, permanently. `AppConfig` has never carried an `http` key and does
|
|
50
58
|
not gain one: `@ultimat3/core` is tier 0 and cannot hold this package's types.
|
|
51
59
|
|
|
60
|
+
`cors.allowHeaders` and `cors.exposeHeaders` default to what the typed clients send and read, off
|
|
61
|
+
core's constants (`idempotency-key`, `x-request-timeout-ms`, `traceparent`; `x-ultimate-records`,
|
|
62
|
+
`x-ultimate-dates`, `x-ultimate-replayed`, `ratelimit-*`, `deprecation`, `sunset`, `link`), so
|
|
63
|
+
listing an origin is the whole declaration. A list of the app's own replaces the default whole.
|
|
64
|
+
|
|
52
65
|
`AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>` — `port`, `hostname`, `dev`,
|
|
53
66
|
`buildId`, `signInPath`, `trustProxy`, `trustedProxyHops` and `rateLimit.scope` are the boot's, and
|
|
54
67
|
writing one here is a **type error** rather than a value silently overwritten at the next boot.
|
|
@@ -60,17 +73,17 @@ something.
|
|
|
60
73
|
|
|
61
74
|
```
|
|
62
75
|
request-id → admit → trace → context → locale → auth → rate-limit → csrf → body → authz
|
|
63
|
-
→ handler → cache-headers → (error-map) → response
|
|
76
|
+
→ handler → cache-headers → (error-map) → response → compress
|
|
64
77
|
```
|
|
65
78
|
|
|
66
79
|
Exported as `PIPELINE_STAGES`, each entry carrying a `why`. `pipeline.test.ts`
|
|
67
|
-
asserts the order
|
|
80
|
+
asserts the order. Ordering rules worth restating:
|
|
68
81
|
|
|
69
82
|
| Rule | Reason |
|
|
70
83
|
|---|---|
|
|
71
84
|
| 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 |
|
|
72
85
|
| 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 |
|
|
73
|
-
| 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 |
|
|
86
|
+
| 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. A route declaring `meta.csrf: 'cookieless'` (an RFC 6749 token endpoint) passes an anonymous caller; it needs its own `meta.authenticate`, or `httpRouter` refuses it `X_CONFIG_INVALID` |
|
|
74
87
|
| csrf before body | a forged write never makes the server allocate its payload |
|
|
75
88
|
| rate-limit before body | a limited request never allocates its payload |
|
|
76
89
|
| body before authz | policies take parsed input as their subject |
|
|
@@ -116,6 +129,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
116
129
|
| `buildId: null` | skew detection off, whatever `BUILD_ID` says; `hostname` never defaults from `HOSTNAME` (the container id under Docker) |
|
|
117
130
|
| 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` |
|
|
118
131
|
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
132
|
+
| a public text answer, 1 KiB to 256 KiB, with a declared length | gzip on the wire, from the last stage (`compress`), `vary: accept-encoding` and a weak `etag`. A private answer, a signed-in one that is not `immutable`, `no-transform`, a range or an undeclared length stays identity and unread. HEAD carries GET's coded headers. `html`, `textResponse`, `jsonResponse` declare their bytes; `stream` never does. Exported: `isCompressibleType(contentType)`, `COMPRESS_MIN_BYTES`, `COMPRESS_MAX_BYTES` |
|
|
119
133
|
|
|
120
134
|
`handle()` resolves to a Response, always — a stage that throws after the handler, or while
|
|
121
135
|
rendering another stage's throw, degrades to `X_PIPELINE_FINALIZE_FAILED` (500, the stage named in
|
|
@@ -305,6 +319,12 @@ every stage (middleware, finalize, security headers), and an upgraded request mu
|
|
|
305
319
|
halves above cannot carry a case, the fallback is a long-poll `action` (`GET`-shaped, returning
|
|
306
320
|
`{ frames, cursor }` and re-called on return), which is what the app that asked shipped.
|
|
307
321
|
|
|
322
|
+
A client that is **not a browser** (a device agent) is the one exception, and it is declared, not
|
|
323
|
+
hand-written: `deviceSocket()` from `@ultimat3/realtime/server` ([Realtime](../../wiki/Realtime.md#device-sockets)).
|
|
324
|
+
Several mounts on one listener — the sync node's beside a device socket's — are joined by
|
|
325
|
+
`joinWebSocketMounts(mounts)`: routed by path at the upgrade, and each socket's events back to the
|
|
326
|
+
mount that upgraded it, keyed by the `data` object it upgraded with (`As of 2026-11-06`).
|
|
327
|
+
|
|
308
328
|
### A second door: `bearerMount` (`/v1/*`)
|
|
309
329
|
|
|
310
330
|
`As of 22.6.0`. `bearerMount({ prefix, routes, scopes, resolveToken, rateLimit?, rateLimitStore? })`
|
|
@@ -318,7 +338,9 @@ exactly three things. Apps declare it through `defineApi({ http: { mounts } })`
|
|
|
318
338
|
| no / unresolved token | 401, `WWW-Authenticate: Bearer` / `Bearer error="invalid_token"` |
|
|
319
339
|
| a primitive outside the token's `scopes` | 404 `X_ROUTE_NOT_FOUND`, identical to an unknown path — MCP's hidden-tool rule |
|
|
320
340
|
| per-token allowance | `RateLimit-Limit/-Remaining/-Reset` on every answer, 429 + `Retry-After` past it; keyed by a SHA-256 of the token |
|
|
321
|
-
| paths | `/api/<x>` → `<prefix>/<x>`, `/_x/query/<x>` → `GET <prefix>/<x>` (`mountedPath`) |
|
|
341
|
+
| paths | `/api/<x>` → `<prefix>/<x>`, `/_x/query/<x>` → `GET <prefix>/<x>` (`mountedPath`); a pinned `:param` template keeps its params — `/orders/:id/refund` → `<prefix>/orders/:id/refund`. Only the prefix is parameter-free |
|
|
342
|
+
| `rateLimit` declared | spent once per request in the mount's authenticator, before `body` and `authz`, so a 422 or a 403 pays too; it REPLACES `default` on the mount's routes (`rateLimitedBy: 'handler'`); a route's own `meta.rateLimit` and the tenant allowance still apply. Not declared: `default` stands |
|
|
343
|
+
| `Link: rel="successor-version"` | the mounted path when the successor is in the cut and this token holds its scope, success or refusal; otherwise removed. Any other `Link` is the handler's |
|
|
322
344
|
|
|
323
345
|
`RouteMeta.authenticate` is the mechanism: a route that states one is reached only through it.
|
|
324
346
|
|
|
@@ -338,8 +360,9 @@ mechanism. It is a plain function and not a pipeline stage, because the secret i
|
|
|
338
360
|
and only the route knows which one applies.
|
|
339
361
|
|
|
340
362
|
```ts
|
|
341
|
-
//
|
|
342
|
-
//
|
|
363
|
+
// The check alone. Mounted, it is the `verify` of a `webhookReceiver()` from @ultimat3/jobs —
|
|
364
|
+
// verify(inbound) { return verifyWebhookSignature(inbound.request(), { secret }) } — which
|
|
365
|
+
// queues durably before it acks; a hand-mounted route like this one acks before anything is kept.
|
|
343
366
|
import { verifyWebhookSignature } from '@ultimat3/http';
|
|
344
367
|
|
|
345
368
|
declare const env: { readonly PARTNER_WEBHOOK_SECRET: string }; // the app's defineEnv() result
|
|
@@ -380,6 +403,36 @@ wire format — the canonical string, `WEBHOOK_ID_HEADER`, `WEBHOOK_SIGNATURE_HE
|
|
|
380
403
|
(`webhook-signature.ts`), and a receiver imports the names from there: 25.0.0 dropped this
|
|
381
404
|
package's re-exports of them, and of `escapeHtml` and `readCookie` (`X_HELPER_COPY`).
|
|
382
405
|
|
|
406
|
+
### A vendor's hmac: `verifyBodyHmac`
|
|
407
|
+
|
|
408
|
+
Every other sender signs its own way, so no vendor verifier ships. `verifyBodyHmac` is the one
|
|
409
|
+
shared step: HMAC-SHA256 under a UTF-8 secret over the bytes you name, compared in constant time,
|
|
410
|
+
under any secret of a rotation list. Which bytes (`body`, `${t}.${body}`) and which header are the
|
|
411
|
+
vendor's, and yours to pass. A secret the vendor hands out encoded (Svix's base64 `whsec_`) is not
|
|
412
|
+
this helper's: the mail receivers decode their own. It answers `{ bytes, text }` — read the event
|
|
413
|
+
id from `text`, never from an unsigned header.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { verifyBodyHmac } from '@ultimat3/http';
|
|
417
|
+
|
|
418
|
+
declare const bytes: Uint8Array; // the exact body — `webhookReceiver`'s inbound.bytes
|
|
419
|
+
declare const headers: Headers;
|
|
420
|
+
|
|
421
|
+
const { text } = verifyBodyHmac({
|
|
422
|
+
bytes,
|
|
423
|
+
secret: [Bun.env['VENDOR_SECRET'] ?? '', Bun.env['VENDOR_SECRET_PREVIOUS'] ?? ''],
|
|
424
|
+
given: headers.get('x-hub-signature-256'),
|
|
425
|
+
encoding: 'hex', // or 'base64'
|
|
426
|
+
prefix: 'sha256=', // compared, never stripped
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
| Answer | When |
|
|
431
|
+
|---|---|
|
|
432
|
+
| `{ bytes, text }` | one secret's mac, behind `prefix`, equals `given`; `text` is `null` when the bytes are not UTF-8 |
|
|
433
|
+
| `X_WEBHOOK_SIGNATURE_INVALID` (401) | no signature, or none matches. The cause names the encoding, the prefix and the secret count — never a mac, a secret or a byte |
|
|
434
|
+
| `X_CONFIG_INVALID` | an empty or unset secret, an empty list, or one empty entry in it |
|
|
435
|
+
|
|
383
436
|
## The exact body bytes
|
|
384
437
|
|
|
385
438
|
`As of 2026-09-24`. `UltimateRequest#bodyBytes()` returns the body exactly as it was sent,
|
|
@@ -413,6 +466,27 @@ export const ingestSesEvent = action({
|
|
|
413
466
|
| one reader | `bodyRaw()` and `bodyBytes()` share one capped read, so an action that reads both reads the stream once |
|
|
414
467
|
| off HTTP | `X_NO_REQUEST`: a job or an MCP call has no body bytes |
|
|
415
468
|
|
|
469
|
+
## Serving a stored object
|
|
470
|
+
|
|
471
|
+
`As of 2026-11-02`. `objectResponse(request, { object, open }, { cache?, headers? })` is the one
|
|
472
|
+
correct answer to a GET for bytes held elsewhere: `/_storage`, `/media`, a query's download, and your
|
|
473
|
+
own route for an object a stranger may fetch.
|
|
474
|
+
|
|
475
|
+
| Argument | Is |
|
|
476
|
+
|---|---|
|
|
477
|
+
| `request` | anything with `header(name)` |
|
|
478
|
+
| `object` | `{ contentType, size, etag?, lastModified? }`. Structural: this package imports no storage, and a `StorageObject` fits. No etag (or `''`): no `etag` header, no 304 |
|
|
479
|
+
| `open(range?)` | exactly those bytes as a `BodyInit`, or a promise of them. A 304 and a 416 open nothing |
|
|
480
|
+
| `cache` | a `CacheHint` applied to every status. Omitted, the route's `meta.cache` decides |
|
|
481
|
+
| `headers` | replaces the stored-object rule (`attachment` + `sandbox` for any type but a raster image, audio or video), for a caller with its own |
|
|
482
|
+
|
|
483
|
+
A failing `if-range` (another etag, a weak one, a date that is not exact) ignores the range and
|
|
484
|
+
answers the whole object, so no window of an older revision is spliced onto this one.
|
|
485
|
+
|
|
486
|
+
`notModified(request, etag, headers?)` is the 304 alone, or `undefined`, for bytes not yet in hand: a
|
|
487
|
+
route that would have to decode to answer asks it first. `*`, a weak validator and a comma list all
|
|
488
|
+
count.
|
|
489
|
+
|
|
416
490
|
## Setting a cookie
|
|
417
491
|
|
|
418
492
|
`As of 2026-10-06`. A route handler, a page `load()` or an action cannot hand the pipeline a
|
|
@@ -446,6 +520,22 @@ export const choosePreferences = action({
|
|
|
446
520
|
| off HTTP | `X_NO_REQUEST` in a job, a task or an impersonated child context; `X_NO_CONTEXT` outside any |
|
|
447
521
|
| 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
522
|
|
|
523
|
+
## Rendering a shared document
|
|
524
|
+
|
|
525
|
+
`withAnonymousRequest(ctx, url, (scope) => …)`: run as this request with nobody behind it. What
|
|
526
|
+
`@ultimat3/cli` renders `isr` and `static` in, so a stored document holds no visitor's answer.
|
|
527
|
+
|
|
528
|
+
| | |
|
|
529
|
+
|---|---|
|
|
530
|
+
| the actor | anonymous, with registered services rebuilt for it |
|
|
531
|
+
| the inbound headers | none: no header is in a stored page's key, and `accept-language` is already `ctx.locale` |
|
|
532
|
+
| `url` | the one handed in: the document's address, already narrowed to its key |
|
|
533
|
+
| `tz` | the app's default; `locale` stays, because it is in the key |
|
|
534
|
+
| `ip`, `peer`, `clientBuildId`, `authz`, `rateLimit`, `readBody` | empty |
|
|
535
|
+
| a redirect | `setRedirect()` lands on `scope`: read it with `takeRedirect(scope)` |
|
|
536
|
+
| the response headers | `scope.headers` is `ctx.headers` |
|
|
537
|
+
| the outer `ctx` | unchanged: the pipeline still answers the identified request privately |
|
|
538
|
+
|
|
449
539
|
## Errors
|
|
450
540
|
|
|
451
541
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
|
@@ -456,6 +546,11 @@ export const choosePreferences = action({
|
|
|
456
546
|
One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
|
|
457
547
|
overlay — so the `code`/`cause`/`fix` strings can never diverge.
|
|
458
548
|
|
|
549
|
+
Every problem document carries `retry` (`terminal`, `retryable` or `retry-after`, core's
|
|
550
|
+
`errorRetry`) at its root, a 401 and a scope 404 included. A hidden 5xx answers the class and
|
|
551
|
+
nothing else about the failure. It is what a client maps to an exception, so no app carries a
|
|
552
|
+
middleware to add it.
|
|
553
|
+
|
|
459
554
|
A problem document's `type` and `docs` answer different questions and are two different
|
|
460
555
|
values. `type` is `problemTypeFor(code)` — `urn:ultimate:error:X_BODY_INVALID`, the RFC-9457
|
|
461
556
|
identifier a client switches on, per code, with no host to resolve or rot. `docs` is
|
|
@@ -490,6 +585,11 @@ as is `issues`, which has its own top-level home. `@ultimat3/action`'s typed cli
|
|
|
490
585
|
member back on the rebuilt error's `meta`, so an island reads `error.meta.sessionId` where it
|
|
491
586
|
used to run a regex over `cause`.
|
|
492
587
|
|
|
588
|
+
A framework code's public keys are not an app's call: they are one reviewed table,
|
|
589
|
+
`problem-meta-framework.ts`, held by its test to a 4xx code and keys an app's declaration could
|
|
590
|
+
carry, none secret-shaped. One row, `As of 2026-11`: `X_METER_EXCEEDED` carries
|
|
591
|
+
`meta: { meter, limit, resetsAt }` (its delay is the `Retry-After` header).
|
|
592
|
+
|
|
493
593
|
### Error classes
|
|
494
594
|
|
|
495
595
|
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
@@ -511,3 +611,16 @@ nothing for: the error it returns is thrown in place of `X_ROUTE_NOT_FOUND`, `un
|
|
|
511
611
|
plain miss. Synchronous, given only the request line, never asked about a matched route or a 405.
|
|
512
612
|
`@ultimat3/action` answers it for a path that is one of its actions under a `pathStyle` this server
|
|
513
613
|
does not serve (`X_CONTRACT_DRIFT`, 404).
|
|
614
|
+
|
|
615
|
+
## Supporting exports
|
|
616
|
+
|
|
617
|
+
`As of 2026-11-02`.
|
|
618
|
+
|
|
619
|
+
Types of `objectResponse`.
|
|
620
|
+
|
|
621
|
+
| Name | What |
|
|
622
|
+
|---|---|
|
|
623
|
+
| `ServableObject` | what the answer needs before it opens anything. `StorageObject` is one |
|
|
624
|
+
| `ObjectSource` | `{ object, open }`: `open(range?)` returns the body |
|
|
625
|
+
| `ObjectRequest` | the one thing asked of the request, `header(name)`, so a test needs no `Request` |
|
|
626
|
+
| `ObjectResponseOptions` | `cache` and `headers`. `cache` omitted, the route's declared `meta.cache` decides |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"http",
|
|
10
|
+
"router",
|
|
11
|
+
"server",
|
|
12
|
+
"problem-json"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Routes-And-Render-Modes",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"sideEffects": [
|
|
@@ -35,9 +45,9 @@
|
|
|
35
45
|
"test": "bun test"
|
|
36
46
|
},
|
|
37
47
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/i18n": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
41
|
-
"@ultimat3/time": "
|
|
48
|
+
"@ultimat3/core": "30.1.0",
|
|
49
|
+
"@ultimat3/i18n": "30.1.0",
|
|
50
|
+
"@ultimat3/schema": "30.1.0",
|
|
51
|
+
"@ultimat3/time": "30.1.0"
|
|
42
52
|
}
|
|
43
53
|
}
|
package/src/bearer-mount.ts
CHANGED
|
@@ -4,10 +4,18 @@
|
|
|
4
4
|
// so there is no second projection of an action to drift from the first. What the mount adds is
|
|
5
5
|
// the credential (only the bearer token authenticates here; a session cookie reaches nothing), the
|
|
6
6
|
// cut (a primitive outside the caller's scopes answers 404, the way MCP answers an unknown tool),
|
|
7
|
-
// and a per-token allowance with `RateLimit-*` and `Retry-After`.
|
|
7
|
+
// and a per-token allowance with `RateLimit-*` and `Retry-After`, which replaces `default`. A route
|
|
8
|
+
// path may be a `:param` template (an action's `http: { path: '/orders/:id/refund' }`): only the
|
|
9
|
+
// PREFIX is parameter-free, and `/v1/orders/:id/refund` is matched by the router as any route is.
|
|
8
10
|
|
|
9
11
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ACTION_PATH_PREFIX,
|
|
14
|
+
QUERY_PATH_PREFIX,
|
|
15
|
+
successorLink,
|
|
16
|
+
successorPathOf,
|
|
17
|
+
systemClock,
|
|
18
|
+
} from '@ultimat3/core';
|
|
11
19
|
import type { RequestContext } from './context';
|
|
12
20
|
import { bearerMountInvalid, routeNotFound } from './errors';
|
|
13
21
|
import type { RateLimitDecision, RateLimitStore } from './rate-limit';
|
|
@@ -58,7 +66,11 @@ export interface BearerMountInput {
|
|
|
58
66
|
*/
|
|
59
67
|
readonly scopes: Readonly<Record<string, readonly string[]>>;
|
|
60
68
|
readonly resolveToken: BearerResolver;
|
|
61
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Requests per window per TOKEN (keyed by a hash of the token itself, never the user). Declared,
|
|
71
|
+
* it REPLACES the `default` bucket on the mount's routes, which would otherwise cap it at 120 a
|
|
72
|
+
* minute; absent, `default` stands. A route's own `meta.rateLimit` and the tenant allowance stay.
|
|
73
|
+
*/
|
|
62
74
|
readonly rateLimit?: BearerMountRateLimit | undefined;
|
|
63
75
|
/** Where the per-token buckets live. Defaults to per-process memory; a fleet passes a shared one. */
|
|
64
76
|
readonly rateLimitStore?: RateLimitStore | undefined;
|
|
@@ -147,7 +159,7 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
147
159
|
const clock = input.clock ?? systemClock;
|
|
148
160
|
// The caller each request resolved to, keyed by ITS context — never a module-level slot two
|
|
149
161
|
// concurrent requests could share.
|
|
150
|
-
const callers = new WeakMap<RequestContext, { caller: BearerCaller
|
|
162
|
+
const callers = new WeakMap<RequestContext, { caller: BearerCaller }>();
|
|
151
163
|
|
|
152
164
|
// One authenticator per mounted route, closed over ITS scope, so the cut is decided in the `auth`
|
|
153
165
|
// stage — before `body` and `authz`. Decided in the handler, an out-of-scope token was answered
|
|
@@ -171,14 +183,15 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
171
183
|
}
|
|
172
184
|
// Hidden, never forbidden: a 403 would confirm the primitive exists to a token that was
|
|
173
185
|
// not issued for it — the enumeration MCP's catalog refuses the same way.
|
|
174
|
-
// And never free:
|
|
175
|
-
//
|
|
176
|
-
//
|
|
186
|
+
// And never free: every resolved token pays HERE, once, in scope or not. The handler spent
|
|
187
|
+
// it once, so a body the `body` stage refused or a policy `authz` denied went unmetered.
|
|
188
|
+
// Out of scope, a token walking the cut would go unmetered too, and `RateLimit-*` on one
|
|
189
|
+
// 404 but not another would tell hidden from unknown.
|
|
190
|
+
await spend(tokenKey(token), ctx);
|
|
177
191
|
if (scope === null || !caller.scopes.has(scope)) {
|
|
178
|
-
await spend(tokenKey(token), ctx);
|
|
179
192
|
throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
180
193
|
}
|
|
181
|
-
callers.set(ctx, { caller
|
|
194
|
+
callers.set(ctx, { caller });
|
|
182
195
|
return caller.actor;
|
|
183
196
|
};
|
|
184
197
|
|
|
@@ -200,6 +213,8 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
200
213
|
|
|
201
214
|
const mounted: Route[] = [];
|
|
202
215
|
const seen = new Map<string, string>();
|
|
216
|
+
// Filled by the loop below, read per request: the whole cut, by the path `/api` serves it at.
|
|
217
|
+
const cut: Cut = new Map();
|
|
203
218
|
for (const [name, scope] of [...scopeOf.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
204
219
|
for (const route of byName.get(name) ?? []) {
|
|
205
220
|
const path = mountedPath(prefix, route.path);
|
|
@@ -209,10 +224,20 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
209
224
|
throw bearerMountInvalid(prefix, `${name} and ${owner} would both be served at ${slot}`);
|
|
210
225
|
}
|
|
211
226
|
seen.set(slot, name);
|
|
227
|
+
cut.set(route.path, (cut.get(route.path) ?? new Set<string>()).add(scope));
|
|
212
228
|
mounted.push({
|
|
213
229
|
method: route.method,
|
|
214
230
|
path,
|
|
215
|
-
meta: {
|
|
231
|
+
meta: {
|
|
232
|
+
...route.meta,
|
|
233
|
+
auth: 'required',
|
|
234
|
+
authenticate: authenticatorOf(scope),
|
|
235
|
+
// The authenticator spends the allowance, so the stage spends no `default` beside it: a
|
|
236
|
+
// mount declaring 12,000 a minute was capped at 120 per route, silently.
|
|
237
|
+
...(bucket !== undefined && route.meta.rateLimit === undefined
|
|
238
|
+
? { rateLimitedBy: 'handler' as const }
|
|
239
|
+
: {}),
|
|
240
|
+
},
|
|
216
241
|
handler: async (request: UltimateRequest, ctx: RequestContext) => {
|
|
217
242
|
const resolved = callers.get(ctx);
|
|
218
243
|
// The authenticator already answered an out-of-scope token; this is the fail-closed
|
|
@@ -220,8 +245,12 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
220
245
|
if (resolved === undefined || !resolved.caller.scopes.has(scope)) {
|
|
221
246
|
throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
222
247
|
}
|
|
223
|
-
|
|
224
|
-
|
|
248
|
+
try {
|
|
249
|
+
return await route.handler(request, ctx);
|
|
250
|
+
} finally {
|
|
251
|
+
// A `finally`: a deprecated route that refuses still announces its retirement.
|
|
252
|
+
followableSuccessor(ctx.headers, prefix, cut, resolved.caller.scopes);
|
|
253
|
+
}
|
|
225
254
|
},
|
|
226
255
|
});
|
|
227
256
|
}
|
|
@@ -229,6 +258,32 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
|
|
|
229
258
|
return [...mounted, ...opaquePrefix(prefix, authenticatorOf(null))];
|
|
230
259
|
}
|
|
231
260
|
|
|
261
|
+
/** The mount's cut: a route's `/api` path → the scopes that reach it. */
|
|
262
|
+
type Cut = Map<string, Set<string>>;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A successor link the route rendered names the `/api` door, which refuses a bearer caller. In the
|
|
266
|
+
* cut, under a scope this token holds, it becomes the mounted path. Otherwise it goes: the caller
|
|
267
|
+
* cannot follow it, and naming a primitive outside its scopes confirms what the cut answers 404
|
|
268
|
+
* for. Any other `Link` is the handler's own and is not this mount's to touch.
|
|
269
|
+
*/
|
|
270
|
+
const followableSuccessor = (
|
|
271
|
+
headers: Headers,
|
|
272
|
+
prefix: string,
|
|
273
|
+
cut: Cut,
|
|
274
|
+
held: ReadonlySet<string>,
|
|
275
|
+
): void => {
|
|
276
|
+
const link = headers.get('link');
|
|
277
|
+
const served = link === null ? undefined : successorPathOf(link);
|
|
278
|
+
if (served === undefined) return;
|
|
279
|
+
const reaching = cut.get(served);
|
|
280
|
+
if (reaching !== undefined && [...reaching].some((scope) => held.has(scope))) {
|
|
281
|
+
headers.set('link', successorLink(mountedPath(prefix, served)));
|
|
282
|
+
} else {
|
|
283
|
+
headers.delete('link');
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
232
287
|
/** Every method a client can send. HEAD is GET's (`router.ts`'s `routeFor`), never its own route. */
|
|
233
288
|
const CATCH_ALL_METHODS = HTTP_METHODS.filter((method) => method !== 'HEAD');
|
|
234
289
|
|
package/src/cache-policy.ts
CHANGED
|
@@ -10,6 +10,14 @@ import type { Route } from './router';
|
|
|
10
10
|
/** The one answer for an identified request, whoever asked. */
|
|
11
11
|
export const PRIVATE_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A response the CALLER's browser may keep, but must revalidate before every use: what a read
|
|
15
|
+
* declared `etag: true` answers. Written by the handler as a header, not a `CacheHint` — a hint has
|
|
16
|
+
* no `no-cache` directive, and `max-age=0` lets a browser serve the copy stale when offline. The
|
|
17
|
+
* stage leaves it alone (`offersSharedCache` is false), and `replayableExchange` admits the 304.
|
|
18
|
+
*/
|
|
19
|
+
export const PRIVATE_REVALIDATE = 'private, no-cache';
|
|
20
|
+
|
|
13
21
|
// `public` or an `s-maxage` is an OFFER to a shared cache; `immutable` withdraws the question,
|
|
14
22
|
// because it asserts the body is a function of the URL alone — which is what a content-addressed
|
|
15
23
|
// island chunk or image is, and demoting those would re-download every chunk on every navigation
|