@ultimat3/http 29.3.2 → 30.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.
Files changed (45) hide show
  1. package/CLAUDE.md +26 -51
  2. package/README.md +119 -6
  3. package/package.json +15 -5
  4. package/src/bearer-mount.ts +67 -12
  5. package/src/byte-range.ts +69 -0
  6. package/src/cache-policy.ts +8 -0
  7. package/src/compress-read.ts +115 -0
  8. package/src/compress.ts +173 -0
  9. package/src/config.ts +1 -7
  10. package/src/context.ts +50 -0
  11. package/src/cors.ts +43 -4
  12. package/src/csrf.ts +41 -8
  13. package/src/deadline.ts +2 -2
  14. package/src/error-facts.ts +26 -1
  15. package/src/error-map-http.ts +3 -0
  16. package/src/error-map-tier-0.ts +26 -0
  17. package/src/error-map-tier-1.ts +24 -0
  18. package/src/error-map-tier-2.ts +16 -0
  19. package/src/error-map-tier-3.ts +30 -0
  20. package/src/error-map-tier-4.ts +33 -1
  21. package/src/error-page.ts +6 -7
  22. package/src/error-status.ts +26 -17
  23. package/src/error-titles.ts +2 -0
  24. package/src/errors.ts +27 -12
  25. package/src/finalize.ts +1 -0
  26. package/src/index.ts +20 -2
  27. package/src/locale.ts +4 -1
  28. package/src/object-response.ts +134 -0
  29. package/src/overlay.ts +13 -2
  30. package/src/pipeline.ts +6 -1
  31. package/src/preferences.ts +4 -0
  32. package/src/problem-meta-framework.ts +21 -0
  33. package/src/problem-meta.ts +23 -15
  34. package/src/rate-limit-degrade.ts +10 -2
  35. package/src/rate-limit-postgres.ts +22 -9
  36. package/src/rate-limit-store-deadline.ts +60 -0
  37. package/src/response.ts +29 -5
  38. package/src/router.ts +9 -0
  39. package/src/server-websocket.ts +35 -0
  40. package/src/server.ts +8 -4
  41. package/src/stages.ts +22 -5
  42. package/src/stored-object-headers.ts +43 -0
  43. package/src/webhook-hmac.ts +73 -0
  44. package/src/webhook-verify.ts +26 -5
  45. 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`): same
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)`** — the entry at
97
- `entries.length - hops`, never `[0]`; a short chain trusts nothing. **Except `x-forwarded-proto`**
98
- (`forwardedProto`): proxies usually OVERWRITE it, so a list shorter than `hops` reads its first
99
- entry (an overwrite erased the client's value; what is left a trusted hop wrote) — HSTS behind
100
- two hops. A full-length list is read like an address. `trustProxy` defaults to false
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 an `Authorization`
118
- header, and an anonymous request with NEITHER `Origin` nor `sec-fetch-site` (not a browser).
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 and an app declares its codes
164
- with `registerErrorStatus()` (refuses a framework code). No projection of the app's half.
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** (`spendUnauthenticated`,
208
- `rate-limit-stage.ts`): `defaultBucket` under `unauthenticated|ip:<address>`, not route-scoped,
209
- spent only on failure — the 401 leaves before `rate-limit`. Covers the bearer mount's bad tokens.
210
- Spent, it REFUSES the address before `authenticate()` (`refuseExhaustedAddress`, via the store's
211
- read-only `peek` — never a take: that was an upsert per signed-in page),
212
- a valid credential included; no `ctx.ip`, no gate. Signed-in callers behind that NAT wait too.
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; `/_x` renders it. Ordering rules worth restating:
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
- // apps/web/webhooks/partner.ts — mounted as a plain HTTP route through the `routes` runtime
342
- // override (apps/web/runtime.ts); `api/` holds no route file
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": "29.3.2",
3
+ "version": "30.0.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": "29.3.2",
39
- "@ultimat3/i18n": "29.3.2",
40
- "@ultimat3/schema": "29.3.2",
41
- "@ultimat3/time": "29.3.2"
48
+ "@ultimat3/core": "30.0.0",
49
+ "@ultimat3/i18n": "30.0.0",
50
+ "@ultimat3/schema": "30.0.0",
51
+ "@ultimat3/time": "30.0.0"
42
52
  }
43
53
  }
@@ -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 { ACTION_PATH_PREFIX, QUERY_PATH_PREFIX, systemClock } from '@ultimat3/core';
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
- /** Requests per window per TOKEN (keyed by a hash of the token itself, never the user). */
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; key: string }>();
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: the refusal spends the token's allowance as a served call does (which the
175
- // handler charges, once, so the two paths never both spend) — else a token walks the cut
176
- // unmetered, and `RateLimit-*` on one 404 but not another tells hidden from unknown.
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, key: tokenKey(token) });
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: { ...route.meta, auth: 'required', authenticate: authenticatorOf(scope) },
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
- await spend(resolved.key, ctx);
224
- return await route.handler(request, ctx);
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