@ultimat3/http 24.0.0 → 25.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 +39 -21
- package/README.md +64 -25
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/config.ts +27 -29
- package/src/context.ts +10 -10
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/deadline.ts +2 -5
- package/src/error-map-tier-0.ts +2 -0
- package/src/error-map-tier-1.ts +6 -0
- package/src/error-map-tier-2.ts +2 -0
- package/src/error-map-tier-3.ts +8 -0
- package/src/error-map-tier-4.ts +24 -0
- package/src/error-page.ts +2 -2
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +58 -94
- package/src/forwarded.ts +20 -5
- package/src/health-disclosure.ts +0 -3
- package/src/hooks.ts +6 -5
- package/src/index.ts +31 -21
- package/src/navigation.ts +14 -2
- package/src/pipeline.ts +12 -8
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-postgres.ts +3 -3
- package/src/rate-limit-stage.ts +13 -4
- package/src/rate-limit.ts +22 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +2 -2
- package/src/response.ts +2 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +16 -1
- package/src/server.ts +74 -24
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +15 -9
- package/src/validate.ts +2 -2
- package/src/webhook-verify.ts +17 -1
package/CLAUDE.md
CHANGED
|
@@ -13,7 +13,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
13
13
|
## Rules — configuration
|
|
14
14
|
|
|
15
15
|
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`,
|
|
16
|
-
`requestTimeoutMs`, `maxInflight`, `
|
|
16
|
+
`requestTimeoutMs`, `maxInflight`, `trustedProxyHops`) → `X_CONFIG_INVALID`
|
|
17
17
|
(borrowed). `requestTimeoutMs` tops out at `MAX_TIMER_MS` (2^31−1): past it a timer arms ~1 ms.
|
|
18
18
|
`buildId: null` is a declaration (`=== undefined`, never `??`); `hostname` never reads `HOSTNAME`. Helpers carry `Finite` (`assertFiniteCount`, `assertFiniteKeyCap`,
|
|
19
19
|
`assertFiniteBodyLimit`) so `bun run finite-bounds` sees them; `webhook-verify.ts` and
|
|
@@ -28,9 +28,9 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
28
28
|
`trustedProxyHops`, `rateLimit.scope`) is a type error where an app writes it. `mergeHttpConfig`
|
|
29
29
|
merges `security.csp.extend` per directive. `type-pins.ts` refuses a key on `HttpConfig` missing
|
|
30
30
|
from `HttpConfigInput`.
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
`readinessGraceMs
|
|
31
|
+
- **No drain deadline here**: `drain.deadlineMs` is the one budget (core, per role). Key
|
|
32
|
+
`drainTimeoutMs` (deleted 25.0.0) is `X_CONFIG_INVALID` via `refuseDeletedHttpKeys`.
|
|
33
|
+
**`ServerOptions.drain`** passes only `readinessGraceMs`, and only when declared.
|
|
34
34
|
- **`cors.origins: ['*']` with `credentials: true` is `X_CORS_CONFIG_INVALID`.** A refused origin
|
|
35
35
|
still gets `vary: origin`.
|
|
36
36
|
- **A `security.csp.extend` entry must emit only the directive it names**
|
|
@@ -46,7 +46,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
46
46
|
- **`asCtx` is a WIDENING the compiler checks, never a cast** (`RequestContext extends Ctx`;
|
|
47
47
|
`_RequestContextIsACtx` in `type-pins.ts`). `ctx.buildId` is the process's build;
|
|
48
48
|
`ctx.clientBuildId` is the client's claim, read only by `assertBuild()`.
|
|
49
|
-
- **`
|
|
49
|
+
- **`requestContext` COMPOSES `ctxOf()`** — no assertion in this file, and
|
|
50
50
|
`defineService` factories and the service spread are core's. Services go on FIRST so a service
|
|
51
51
|
named `actor` loses to the request's field (`context.test.ts`).
|
|
52
52
|
- **A request's registered services are LAZY and bound to the authenticated actor**
|
|
@@ -68,8 +68,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
68
68
|
handler, policy, idempotency; adds the credential (bearer only, `WWW-Authenticate` on 401), the
|
|
69
69
|
cut (outside the token's scopes = `X_ROUTE_NOT_FOUND`, the MCP rule), and a per-token bucket
|
|
70
70
|
keyed by a SHA-256 of the token. Bad prefix / unknown name / double claim:
|
|
71
|
-
`X_BEARER_MOUNT_INVALID` at construction.
|
|
71
|
+
`X_BEARER_MOUNT_INVALID` at construction. **The cut is decided in the `auth` stage** — one
|
|
72
|
+
authenticator per route, closed over its scope — so an out-of-scope token is 404 before `body`
|
|
73
|
+
(422) and `authz` (403) could describe the primitive. **The prefix is opaque**: `<prefix>/*rest`
|
|
74
|
+
for every method (`opaquePrefix`) answers no token / a bad token with the same 401 challenge a
|
|
75
|
+
served path gives, and a valid token with the same 404 — never 404-vs-401 or a 405 naming methods.
|
|
76
|
+
That 404 SPENDS the token's allowance (once; a served call is charged in the handler), so a
|
|
77
|
+
token cannot walk the cut for free.
|
|
72
78
|
- **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
|
|
79
|
+
- **A dev 4xx asks `hooks.errorPage` first; a 5xx is always the overlay** (#492): the app's file
|
|
80
|
+
when it has one, else the overlay. Production asks for every status (`stages-error-page.test.ts`).
|
|
73
81
|
|
|
74
82
|
## Rules — the pipeline
|
|
75
83
|
|
|
@@ -86,7 +94,10 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
86
94
|
- **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
|
|
87
95
|
`parseTraceparent`). `x-request-id` is gated on `trustProxy`; `traceparent` deliberately is not.
|
|
88
96
|
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`** — the entry at
|
|
89
|
-
`entries.length - hops`, never `[0]`; a short chain trusts nothing. `
|
|
97
|
+
`entries.length - hops`, never `[0]`; a short chain trusts nothing. **Except `x-forwarded-proto`**
|
|
98
|
+
(`forwardedProto`): proxies usually OVERWRITE it, so a list shorter than `hops` reads its first
|
|
99
|
+
entry (an overwrite erased the client's value; what is left a trusted hop wrote) — HSTS behind
|
|
100
|
+
two hops. A full-length list is read like an address. `trustProxy` defaults to false
|
|
90
101
|
and requires `trustedProxyHops`. `x-forwarded-proto` (HSTS) and Envoy XFCC (`peer-identity.ts`) ride
|
|
91
102
|
it; `ctx.peer` is `null` unless trusted AND `trustClientCertHeader` is declared (appending to
|
|
92
103
|
XFF is no promise of stripping a client-sent cert header), and is never an actor.
|
|
@@ -131,12 +142,13 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
131
142
|
set (`addVary`). An offer is `public`, `s-maxage`, or any `max-age` / `must-revalidate` /
|
|
132
143
|
`proxy-revalidate` without `private`/`no-store` (RFC 9111 §3).
|
|
133
144
|
- **`Set-Cookie` is appended by the `response` stage, every other context header is `set`** —
|
|
134
|
-
it is the one header that is a list of lines (`pipeline-cookies.test.ts`).
|
|
145
|
+
it is the one header that is a list of lines (`pipeline-cookies.test.ts`). App code writes one
|
|
146
|
+
only via `setCookie`/`deleteCookie` (core's serializer; `bun run set-cookie-literals`).
|
|
135
147
|
- **A redirect the framework writes stays on this origin**: the default-locale redirect goes
|
|
136
148
|
through `normalizePath`; `locationFor` hands over `http:`/`https:` only, else the requested path.
|
|
137
149
|
- **A `cache-control` age is delta-seconds or DROPPED** (`finiteDeltaSeconds`, total, always the
|
|
138
150
|
shorter direction; logs `http.cache_hint_not_delta_seconds`). Its boot half is `route-cache.ts`:
|
|
139
|
-
`
|
|
151
|
+
`httpRouter` refuses a bad `Route.cache` with `X_CONFIG_INVALID`. Both accept the same set; zero
|
|
140
152
|
is legal. `ctx.cache` is not screened.
|
|
141
153
|
- **A handler's own `Response.status` is never rewritten** (`pipeline-handler-status.test.ts`).
|
|
142
154
|
- Health endpoints answer outside the pipeline, so their body is a stranger's: `{ state, ready,
|
|
@@ -168,8 +180,11 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
168
180
|
vocabulary; what the caller sent rides in `meta`; the parser's message goes through `renderThrowable`.
|
|
169
181
|
- **A browser failing `auth: 'required'` is redirected; an agent gets the problem document**
|
|
170
182
|
(`auth-redirect.ts`, in `error-map` before the overlay). `config.signInPath` is `null` until named.
|
|
171
|
-
`nextAfterSignIn` is the ONE `?next=` reader: same-origin paths only,
|
|
172
|
-
|
|
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).
|
|
173
188
|
- **A pathname in a `fix:` goes through `renderFixShellArg`** (`routeNotFound`). **A `code` is gated by
|
|
174
189
|
core's `FRAMEWORK_CODE`** before `x errors explain <code>`, else `x errors list --json`. **A supplied
|
|
175
190
|
`fix:` is taken only from a branded error** (`isUltimateError`).
|
|
@@ -185,10 +200,10 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
185
200
|
`X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN`. Headers report the bucket closest to refusing.
|
|
186
201
|
- **The memory store is bounded** (`forgetAtMs` sweep, `DEFAULT_MAX_RATE_LIMIT_KEYS`, evicting the
|
|
187
202
|
entries closest to FULL first — never LRU).
|
|
188
|
-
- **The limiter takes a `Clock`** (`
|
|
203
|
+
- **The limiter takes a `Clock`** (`rateLimiter({ clock })`); `deps.limiter` is the one seam.
|
|
189
204
|
- **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
|
|
190
|
-
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `
|
|
191
|
-
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `
|
|
205
|
+
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `httpPipeline`) refuses `'shared'` over a
|
|
206
|
+
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `httpServer({ rateLimitStore })`.
|
|
192
207
|
- **A failed `auth: 'required'` is metered in the `auth` stage** (`spendUnauthenticated`,
|
|
193
208
|
`rate-limit-stage.ts`): `defaultBucket` under `unauthenticated|ip:<address>`, not route-scoped,
|
|
194
209
|
spent only on failure — the 401 leaves before `rate-limit`. Covers the bearer mount's bad tokens.
|
|
@@ -200,7 +215,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
200
215
|
refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
|
|
201
216
|
`purgeExpired(nowMs)` takes the caller's clock.
|
|
202
217
|
- **A bucket a route names must be registered**: `withRouteBuckets` merges `meta.rateLimitBucket`
|
|
203
|
-
into the table in `
|
|
218
|
+
into the table in `httpServer` and `httpPipeline`; any disagreement is
|
|
204
219
|
`X_RATE_LIMIT_BUCKET_CONFLICT`. **The installed limiter must hold it too** — `RateLimiter.buckets`
|
|
205
220
|
is declared and `assertRouteBuckets` refuses, never rebinds.
|
|
206
221
|
|
|
@@ -210,8 +225,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
210
225
|
`readWithinLimit` and returns the raw text. The mac is checked BEFORE freshness
|
|
211
226
|
(`X_WEBHOOK_SIGNATURE_STALE` means authentic and old); the window is `Math.abs`; the timestamp is
|
|
212
227
|
digits-only; `:` is refused in id and topic; the comparison is `timingSafeEqual`
|
|
213
|
-
(`bun run secret-compare`). **
|
|
214
|
-
never re-declared here.
|
|
228
|
+
(`bun run secret-compare`). **An empty `secret` is `X_CONFIG_INVALID`.**
|
|
229
|
+
**The FORMAT is core's** (`packages/core/src/webhook-signature.ts`) — never re-declared here.
|
|
215
230
|
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in `e2e/` (`bun test
|
|
216
231
|
packages/http/e2e`), sealed; `start()` calls core's `markListening()`. Never unseal.
|
|
217
232
|
|
|
@@ -231,26 +246,29 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
231
246
|
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
232
247
|
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
233
248
|
| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
|
|
234
|
-
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `
|
|
249
|
+
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `ctxOf`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
|
|
235
250
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
251
|
+
| `set-cookie.ts` | `setCookie`/`deleteCookie`: one line each onto `ctx.headers` |
|
|
236
252
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
237
253
|
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
|
|
238
254
|
| `error-log-level.ts` | the level of the `error-map` stage's one line: `error` for 5xx, `warn` for 401/403/429, `info` for the rest |
|
|
239
|
-
| `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `
|
|
255
|
+
| `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `httpRouter`; the response path's `finiteDeltaSeconds` is the total half of the same rule |
|
|
240
256
|
| `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
|
|
241
257
|
| `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
|
|
242
|
-
| `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling
|
|
258
|
+
| `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling |
|
|
259
|
+
| `error-titles.ts` | the owned codes, their titles and the two `retry-after` classes, registered at import — the one module `sideEffects` lists, imported bare by the barrel and `errors.ts`. Server realm only: a browser reads an http refusal's title off the body and its delay off `Retry-After` (core's decoders), so no http module rides into an island |
|
|
243
260
|
| `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
|
|
244
261
|
| `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
|
|
245
262
|
| `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
|
|
246
263
|
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
247
264
|
| `csrf.ts` | the origin proof an unsafe method from a browser must carry, signed in or not |
|
|
248
265
|
| `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import |
|
|
249
|
-
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only. It negotiates nothing; the cookie is read by
|
|
266
|
+
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only. It negotiates nothing; the cookie is read by core's `readCookie`, imported, never re-exported |
|
|
250
267
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
251
268
|
| `rate-limit-stage.ts` | what the pipeline spends and when: the `rate-limit` stage's list of keys, and the address-keyed allowance the `auth` stage spends for a failed credential |
|
|
252
269
|
| `health-disclosure.ts` | WHO the web role's `/healthz` and `/readyz` tell the detail to: the `healthDetailPeers` config key, its screen, and the trusted-proxy half. The body rule and the peer match are core's (`health-disclosure.ts` there), shared with the sync node |
|
|
253
270
|
| `bearer-mount.ts` | a second door onto existing routes: `Authorization: Bearer` on a prefix, the scope cut, the per-token allowance |
|
|
271
|
+
| `request-facts.ts` | `RequestFacts`, any token resolver's frozen 2nd arg; address = `ctx.ip` |
|
|
254
272
|
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
255
273
|
|
|
256
274
|
## Commands
|
package/README.md
CHANGED
|
@@ -87,12 +87,12 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
87
87
|
| a repeated form field | a LIST, exactly as a repeated query parameter is. One collector for query, urlencoded and multipart; `Object.fromEntries` kept the last value, so a checkbox group reached the schema as one string |
|
|
88
88
|
| a cross-origin request from an origin the allow-list refuses | no `access-control-allow-origin`, but always `vary: origin`, so a shared cache never answers an allowed origin out of the refusal's slot |
|
|
89
89
|
| `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
|
|
90
|
-
| `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test` |
|
|
90
|
+
| `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test`, and any other control character. `nextAfterSignIn` takes the value its caller's parser already decoded and never decodes it again, `As of 2026-10`: `?q=a%26b` lands as `?q=a%26b`, not `?q=a&b`. A code point above U+007F is percent-encoded so a `Location` header can carry it. The answer is the normalised path (`/a/../b` → `/b`), and one whose pathname starts `//` (`/.//evil.test`) is refused; `x-ultimate-location` never carries a same-origin pathname starting `//` either |
|
|
91
91
|
| HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
|
|
92
|
-
| `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `
|
|
93
|
-
| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `
|
|
92
|
+
| `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `httpServer`, because N replicas each holding their own counters enforce N × every configured number |
|
|
93
|
+
| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `httpServer`, because the loser would be a number someone read and nothing applied |
|
|
94
94
|
| a `rateLimit.tenantBucket` naming a bucket nothing declares | `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, because the name would fall through to `default` and a whole tenant's cap would silently be the 120-burst read bucket |
|
|
95
|
-
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `
|
|
95
|
+
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `httpPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
|
|
96
96
|
| a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |
|
|
97
97
|
| `trustProxy: true` with no `trustedProxyHops` | `X_TRUST_PROXY_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `trustProxy` now defaults to `false`, and `x-forwarded-for` is read at `entries.length - hops` — never at `[0]`, which is whatever the client typed |
|
|
98
98
|
| an unsafe method from a browser — signed in, or anonymous with `Origin`/`sec-fetch-site` — that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an EXACT listing in `cors.origins` — anything else is refused before the body is read. `origins: ['*']` lists nobody here: `'*'` is a value for the response header, not a per-origin allowance |
|
|
@@ -100,7 +100,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
100
100
|
| a budget above `2_147_483_647` ms | no timer holds it: `requestTimeoutMs` past it is `X_CONFIG_INVALID`, and an `x-request-timeout-ms` past it is ignored — it used to arm a ~1 ms timer |
|
|
101
101
|
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
102
102
|
| a request while the process is draining | SERVED, with `connection: close` so the client's next request lands on another pod, `As of 2026-09-23` — refusing it failed 598 of 7,690 requests across one helm upgrade on kind, every one on a kept-alive connection inside the readiness grace. Only a STOPPED process (resources closed) answers `X_DRAINING` (503) + `retry-after` |
|
|
103
|
-
| SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `
|
|
103
|
+
| SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `httpServer({ …, drain: appConfig.drain })` so `app.config.ts`'s value is the one applied; omitted, core's default holds and a `configureLifecycle({ readinessGraceMs })` stands |
|
|
104
104
|
| `?locale=es` | the locale source that outranks the cookie and the header (`resolveLocale`'s order), `As of 2026-09-23` — documented in `wiki/I18n.md` and never read before |
|
|
105
105
|
| a 4xx | logged at `warn` (401, 403, 429) or `info` (every other 4xx); only a 5xx is an `error` line |
|
|
106
106
|
| `x-forwarded-client-cert` without `trustClientCertHeader: true` | `ctx.peer` is `null`. **Breaking**: `trustProxy` alone used to read it, and an ingress that appends to `x-forwarded-for` need not strip a certificate header the client sent |
|
|
@@ -134,7 +134,7 @@ reads (refill applied, **no write and no row** — a key never taken is full; `r
|
|
|
134
134
|
|
|
135
135
|
```ts
|
|
136
136
|
import {
|
|
137
|
-
|
|
137
|
+
httpServer,
|
|
138
138
|
defineHttpConfig,
|
|
139
139
|
type RateLimitStore,
|
|
140
140
|
type Route,
|
|
@@ -143,7 +143,7 @@ import {
|
|
|
143
143
|
declare const routes: readonly Route[];
|
|
144
144
|
declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
httpServer({
|
|
147
147
|
routes,
|
|
148
148
|
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
|
|
149
149
|
rateLimitStore: myStore, // whose own scope is 'shared'
|
|
@@ -164,7 +164,7 @@ the one nobody declared. A limiter with `enabled: false` owes no declaration: no
|
|
|
164
164
|
so nothing can be wrong.
|
|
165
165
|
|
|
166
166
|
`rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
|
|
167
|
-
maths stays in `
|
|
167
|
+
maths stays in `rateLimiter`, so every driver agrees on the numbers.
|
|
168
168
|
|
|
169
169
|
**One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
|
|
170
170
|
else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
|
|
@@ -185,9 +185,9 @@ and one `insert … on conflict` per take (a peek is one `select`), so N replica
|
|
|
185
185
|
line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`.
|
|
186
186
|
|
|
187
187
|
```ts
|
|
188
|
-
import {
|
|
188
|
+
import { baseClient, dbExecutor } from '@ultimat3/db';
|
|
189
189
|
import {
|
|
190
|
-
|
|
190
|
+
httpServer,
|
|
191
191
|
defineHttpConfig,
|
|
192
192
|
type PgExecutor,
|
|
193
193
|
postgresRateLimitStore,
|
|
@@ -196,15 +196,11 @@ import {
|
|
|
196
196
|
|
|
197
197
|
declare const routes: readonly Route[];
|
|
198
198
|
|
|
199
|
-
// The
|
|
200
|
-
//
|
|
201
|
-
const
|
|
202
|
-
const executor: PgExecutor = {
|
|
203
|
-
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
204
|
-
client.query<R>({ text, values } satisfies SqlFragment),
|
|
205
|
-
};
|
|
199
|
+
// The pool this process already opened, never a request's open transaction — the boot builds
|
|
200
|
+
// the same executor when it installs the store for you.
|
|
201
|
+
const executor: PgExecutor = dbExecutor(baseClient);
|
|
206
202
|
|
|
207
|
-
|
|
203
|
+
httpServer({
|
|
208
204
|
routes,
|
|
209
205
|
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }),
|
|
210
206
|
rateLimitStore: postgresRateLimitStore({ executor }),
|
|
@@ -217,7 +213,7 @@ rule. `nowMs` is required and must come from the same clock the takes use — me
|
|
|
217
213
|
server's clock instead, the offset between the two reads as refill and deletes buckets a throttled
|
|
218
214
|
caller is still sitting in.
|
|
219
215
|
|
|
220
|
-
The maths reads an injected `Clock`, defaulting to `systemClock`: `
|
|
216
|
+
The maths reads an injected `Clock`, defaulting to `systemClock`: `rateLimiter({ config,
|
|
221
217
|
clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read
|
|
222
218
|
`Date.now()` when nothing passed one, which both production call sites did, so the limiter that
|
|
223
219
|
actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
@@ -227,7 +223,7 @@ actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
|
227
223
|
### A route may bring its own bucket
|
|
228
224
|
|
|
229
225
|
`meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
|
|
230
|
-
`withRouteBuckets` registers them at construction — `
|
|
226
|
+
`withRouteBuckets` registers them at construction — `httpServer` and `httpPipeline` both apply
|
|
231
227
|
it, idempotently — because `defineHttpConfig` runs before any route exists and cannot have them.
|
|
232
228
|
Without that half, a name nothing defined fell through `bucketFor` to `default`: an action
|
|
233
229
|
declaring `limit: 5` ran on 120 burst, and the number reached the OpenAPI document all the same.
|
|
@@ -253,7 +249,7 @@ the same rule as `RateLimitStore.scope` — and `assertRouteBuckets` compares it
|
|
|
253
249
|
at construction. A limiter passed to `PipelineDeps.limiter` that cannot enforce a declared bucket
|
|
254
250
|
is refused rather than rebound: a `RateLimiter` is opaque, so rebinding would mean discarding the
|
|
255
251
|
store it carries, and a caller who built their own limiter may have meant their own numbers. Pass
|
|
256
|
-
the **store** — `
|
|
252
|
+
the **store** — `httpServer({ routes, rateLimitStore })` — and the pipeline builds the limiter
|
|
257
253
|
from the merged table for you.
|
|
258
254
|
|
|
259
255
|
## Routing
|
|
@@ -264,11 +260,11 @@ flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** — a ro
|
|
|
264
260
|
cannot forget to declare its auth posture.
|
|
265
261
|
|
|
266
262
|
```ts
|
|
267
|
-
import {
|
|
263
|
+
import { httpServer, defineHttpConfig, jsonResponse } from '@ultimat3/http';
|
|
268
264
|
|
|
269
|
-
const handle =
|
|
265
|
+
const handle = httpServer({
|
|
270
266
|
routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
|
|
271
|
-
handler: (req) =>
|
|
267
|
+
handler: (req) => jsonResponse({ id: req.param('id') }) }],
|
|
272
268
|
config: defineHttpConfig({ port: 3000 }),
|
|
273
269
|
role: 'web',
|
|
274
270
|
}).start();
|
|
@@ -327,6 +323,12 @@ exactly three things. Apps declare it through `defineApi({ http: { mounts } })`
|
|
|
327
323
|
For API keys, `resolveToken` is `@ultimat3/auth`'s `apiKeyResolver(() => store)` — a live key is
|
|
328
324
|
`{ actor, scopes }`, every wrong one is the same `null`, and a store fault stays a throw.
|
|
329
325
|
|
|
326
|
+
`resolveToken(token, facts)` (`As of 2026-10`): `facts` is `RequestFacts` — `{ address, userAgent,
|
|
327
|
+
origin, path }`, frozen, built by `requestFacts()`. `address` is `ctx.ip`, the pipeline's
|
|
328
|
+
trusted-hop answer, never a forwarding header. `@ultimat3/mcp` hands its resolver the same object
|
|
329
|
+
(`McpRequestFacts`), so one resolver can bind a token to a network or refuse a browser `Origin` on
|
|
330
|
+
both doors. A one-argument resolver still works.
|
|
331
|
+
|
|
330
332
|
## Inbound webhooks
|
|
331
333
|
|
|
332
334
|
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
|
|
@@ -362,6 +364,7 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
362
364
|
| a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one |
|
|
363
365
|
| moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac |
|
|
364
366
|
| the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload |
|
|
367
|
+
| no secret is refused, never verified against | an empty or non-string `secret` (an unset `process.env.X`) throws `X_CONFIG_INVALID` before the request is read — a mac under an empty key is one anyone can compute. Only empty is refused, not short: the sender signs with any non-empty secret |
|
|
365
368
|
| the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay |
|
|
366
369
|
|
|
367
370
|
`X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is
|
|
@@ -369,7 +372,10 @@ well formed and carried a credential, and the credential is what failed. Neither
|
|
|
369
372
|
sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
|
|
370
373
|
|
|
371
374
|
The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
|
|
372
|
-
|
|
375
|
+
wire format — the canonical string, `WEBHOOK_ID_HEADER`, `WEBHOOK_SIGNATURE_HEADER`,
|
|
376
|
+
`WEBHOOK_TOPIC_HEADER`, `WEBHOOK_SIGNATURE_VERSION` — is `@ultimat3/core`'s
|
|
377
|
+
(`webhook-signature.ts`), and a receiver imports the names from there: 25.0.0 dropped this
|
|
378
|
+
package's re-exports of them, and of `escapeHtml` and `readCookie` (`X_HELPER_COPY`).
|
|
373
379
|
|
|
374
380
|
## The exact body bytes
|
|
375
381
|
|
|
@@ -404,6 +410,39 @@ export const ingestSesEvent = action({
|
|
|
404
410
|
| one reader | `bodyRaw()` and `bodyBytes()` share one capped read, so an action that reads both reads the stream once |
|
|
405
411
|
| off HTTP | `X_NO_REQUEST`: a job or an MCP call has no body bytes |
|
|
406
412
|
|
|
413
|
+
## Setting a cookie
|
|
414
|
+
|
|
415
|
+
`As of 2026-10-06`. A route handler, a page `load()` or an action cannot hand the pipeline a
|
|
416
|
+
`Response` of its own, so it calls `setCookie` on the request in scope. The line is
|
|
417
|
+
`@ultimat3/core`'s `serializeSetCookie` — the one serializer — appended to the context's response
|
|
418
|
+
headers, which the `response` stage appends to whatever is sent.
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
import { action, t } from '@ultimat3/action';
|
|
422
|
+
import { deleteCookie, setCookie } from '@ultimat3/http';
|
|
423
|
+
import { allow } from '@ultimat3/policy';
|
|
424
|
+
|
|
425
|
+
export const choosePreferences = action({
|
|
426
|
+
input: t.object({ theme: t.string.max(16), reset: t.boolean }),
|
|
427
|
+
output: t.object({ ok: t.boolean }),
|
|
428
|
+
policy: allow('public'),
|
|
429
|
+
handle({ input }) {
|
|
430
|
+
if (input.reset) deleteCookie('theme', { path: '/' });
|
|
431
|
+
else setCookie('theme', input.theme, { httpOnly: false, maxAge: 60 * 60 * 24 * 365 });
|
|
432
|
+
return { ok: true };
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
| | |
|
|
438
|
+
|---|---|
|
|
439
|
+
| defaults | `Path=/; HttpOnly; Secure; SameSite=Lax` — core's; every option is `SetCookieOptions` |
|
|
440
|
+
| two calls | two `Set-Cookie` lines, in call order. The same name on two paths is two cookies; nothing overwrites |
|
|
441
|
+
| clearing | **`deleteCookie(name, { path, domain })` is the one way**: empty value, `Max-Age=0`, the `Path`/`Domain` (and `Partitioned`) the cookie was set with. `setCookie` with `maxAge: 0`, or an `expires` at or before `ctx.now()`, is `X_COOKIE_INVALID` naming `deleteCookie` |
|
|
442
|
+
| an invalid cookie | `X_COOKIE_INVALID` (core) before anything is appended: a non-token name, `__Host-` without `Secure`, a pair over 4096 octets |
|
|
443
|
+
| off HTTP | `X_NO_REQUEST` in a job, a task or an impersonated child context; `X_NO_CONTEXT` outside any |
|
|
444
|
+
| 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`) |
|
|
445
|
+
|
|
407
446
|
## Errors
|
|
408
447
|
|
|
409
448
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/error-titles.ts"
|
|
9
|
+
],
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -25,16 +28,16 @@
|
|
|
25
28
|
"LICENSE"
|
|
26
29
|
],
|
|
27
30
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
29
32
|
},
|
|
30
33
|
"scripts": {
|
|
31
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
35
|
"test": "bun test"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/i18n": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/core": "25.1.0",
|
|
39
|
+
"@ultimat3/i18n": "25.1.0",
|
|
40
|
+
"@ultimat3/schema": "25.1.0",
|
|
41
|
+
"@ultimat3/time": "25.1.0"
|
|
39
42
|
}
|
|
40
43
|
}
|
package/src/app-config.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// hold this package's types, so an `http` block on `AppConfig` would be a second declaration of
|
|
5
5
|
// `HttpConfigInput` in a package that can never check it against this one.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type HttpConfigInput, refuseDeletedHttpKeys } from './config';
|
|
8
8
|
import type { RateLimitConfig } from './rate-limit';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -47,6 +47,8 @@ export type AppHttpConfig = Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'
|
|
|
47
47
|
let declared: AppHttpConfig | undefined;
|
|
48
48
|
|
|
49
49
|
export const configureHttp = (config: AppHttpConfig): void => {
|
|
50
|
+
// At the declaration, so the stack names the app's own line — not the boot that merged it.
|
|
51
|
+
refuseDeletedHttpKeys(config);
|
|
50
52
|
declared = config;
|
|
51
53
|
};
|
|
52
54
|
|
package/src/auth-redirect.ts
CHANGED
|
@@ -41,33 +41,48 @@ export function signInRedirect(input: {
|
|
|
41
41
|
/**
|
|
42
42
|
* The other half of the round trip: where to send someone once they HAVE signed in.
|
|
43
43
|
*
|
|
44
|
+
* `raw` is the value a query or form parser already decoded ONCE — `props.query.next`, an action's
|
|
45
|
+
* `next` input, `url.searchParams.get('next')` — so it is screened as it arrived and never
|
|
46
|
+
* decoded again. A second decode changed the destination: `signInRedirect` carried
|
|
47
|
+
* `/search?q=a%26b`, the parser handed back exactly that, and decoding it again landed on
|
|
48
|
+
* `?q=a&b`, a different query; `?label=100%25done` threw and fell back. Normalising (below) removes
|
|
49
|
+
* dot segments only; it never decodes, so `%26` is still `%26`.
|
|
50
|
+
*
|
|
44
51
|
* Everything except a same-origin path is refused and `fallback` is used instead. `?next=`
|
|
45
52
|
* arrives from the URL bar, so it is attacker-controlled by definition — an unchecked value here
|
|
46
53
|
* is an open redirect on a page whose entire job is to hold a session, which is the exact shape
|
|
47
54
|
* phishing wants: a real domain, a real login, a hop to somewhere else.
|
|
48
55
|
*
|
|
49
56
|
* Refused: an absolute URL (`https://evil.test/x`), a scheme-relative one (`//evil.test`), a
|
|
50
|
-
* backslash the browser normalises to a slash (`/\evil.test`), a value carrying
|
|
51
|
-
* and anything not starting `/`.
|
|
57
|
+
* backslash the browser normalises to a slash (`/\evil.test`), a value carrying any control
|
|
58
|
+
* character, and anything not starting `/`. A percent-encoded `%2F%2F`, `%5C` or `%09` is NOT
|
|
59
|
+
* refused, because it is not decoded: a browser resolves a `Location` without decoding it, so
|
|
60
|
+
* each is an ordinary path segment on this origin — and the final parse below proves it.
|
|
52
61
|
*
|
|
53
62
|
* The control characters are not cosmetic. A browser DELETES tab, CR and LF from a `Location`
|
|
54
|
-
* before it parses one, so
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
63
|
+
* before it parses one, so `/\t/evil.test` — which starts with a single slash and passes a prefix
|
|
64
|
+
* check — is parsed as `//evil.test`. NUL, ESC and DEL are not stripped, they are refused by
|
|
65
|
+
* `Headers`, which is a 500 on the sign-in page. The URL parser strips the first three the same way
|
|
66
|
+
* a browser does, which is why the last word here is the parse: whatever a client would actually
|
|
67
|
+
* resolve has to still be a path on this origin.
|
|
68
|
+
*
|
|
69
|
+
* The answer is the RESOLVED path, query and fragment — dot segments removed, never the raw
|
|
70
|
+
* string: a value checked by its resolution and returned unresolved let `/.//evil.test` pass as
|
|
71
|
+
* same-origin and reach the client router as `//evil.test`. A resolved pathname starting `//` is
|
|
72
|
+
* refused outright.
|
|
73
|
+
*
|
|
74
|
+
* Before the parse, a code point above U+007F is percent-encoded as UTF-8. The parser hands `/日本`
|
|
75
|
+
* over decoded, `Headers` refuses anything above U+00FF, and a Latin-1 byte in a `Location` is read
|
|
76
|
+
* as UTF-8 by the browser — so the raw form is not a destination any client can follow. A lone
|
|
77
|
+
* surrogate has no UTF-8 encoding and falls back.
|
|
58
78
|
*/
|
|
59
79
|
export function nextAfterSignIn(raw: string | null | undefined, fallback: string): string {
|
|
60
80
|
if (raw === null || raw === undefined || raw === '') return fallback;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return fallback;
|
|
67
|
-
}
|
|
68
|
-
if (!value.startsWith('/')) return fallback;
|
|
69
|
-
if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
|
|
70
|
-
if (/[\t\r\n]/.test(value)) return fallback;
|
|
81
|
+
if (!raw.startsWith('/')) return fallback;
|
|
82
|
+
if (raw.startsWith('//') || raw.startsWith('/\\')) return fallback;
|
|
83
|
+
if (hasControlCharacter(raw)) return fallback;
|
|
84
|
+
const value = encodeNonAscii(raw);
|
|
85
|
+
if (value === undefined) return fallback;
|
|
71
86
|
// An origin no relative path could reach, so any value that resolves off it left this origin.
|
|
72
87
|
const base = 'http://x.invalid';
|
|
73
88
|
let resolved: URL;
|
|
@@ -77,5 +92,29 @@ export function nextAfterSignIn(raw: string | null | undefined, fallback: string
|
|
|
77
92
|
return fallback;
|
|
78
93
|
}
|
|
79
94
|
if (resolved.origin !== base) return fallback;
|
|
80
|
-
|
|
95
|
+
// Dot segments can hide a `//`: `/.//evil.test` resolves on this origin with the pathname
|
|
96
|
+
// `//evil.test`, which is another host the moment anything re-reads it without the base.
|
|
97
|
+
if (resolved.pathname.startsWith('//')) return fallback;
|
|
98
|
+
// The CHECKED string is the returned one. Returning `value` after checking its resolution let
|
|
99
|
+
// the two differ, and the router re-resolved the difference off-site.
|
|
100
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** C0 controls and DEL — the bytes a browser strips from a `Location` or a header refuses. */
|
|
104
|
+
function hasControlCharacter(value: string): boolean {
|
|
105
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
106
|
+
const code = value.charCodeAt(index);
|
|
107
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every run outside printable ASCII as UTF-8 percent-encoding, or `undefined` for a lone surrogate. */
|
|
113
|
+
function encodeNonAscii(value: string): string | undefined {
|
|
114
|
+
try {
|
|
115
|
+
// Controls are already refused, so everything this class matches is above U+007E.
|
|
116
|
+
return value.replace(/[^ -~]+/g, (run) => encodeURI(run));
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
81
120
|
}
|