@ultimat3/http
Advanced tools
| // Single responsibility: the screen a route's `cache` hint gets where it is DECLARED. | ||
| // | ||
| // The layered form the rest of the framework uses: refuse where a value is WRITTEN, be total where | ||
| // it is USED. `cacheControl` is the total half — it drops a field it cannot emit and warns — and | ||
| // this is the half that throws, because at registration the author is standing right there. | ||
| import { routeCacheInvalid } from './errors'; | ||
| import type { CacheHint } from './response'; | ||
| import { isDeltaSeconds } from './response'; | ||
| /** | ||
| * The three `CacheHint` fields that reach a `cache-control` directive as a NUMBER. `tags` and | ||
| * `vary` are string lists and `mode` is a closed union the compiler already decides. | ||
| * | ||
| * A tuple of literals rather than `Object.keys(hint)`: the set is the emitter's, not the caller's, | ||
| * so a field added to `CacheHint` and forgotten here is a compile error at the emitter's next edit | ||
| * rather than a silently unscreened age. | ||
| */ | ||
| const DELTA_SECONDS_FIELDS = [ | ||
| 'maxAgeSeconds', | ||
| 'sMaxAgeSeconds', | ||
| 'staleWhileRevalidateSeconds', | ||
| ] as const; | ||
| /** | ||
| * `isDeltaSeconds` is IMPORTED from `response.ts`, never restated: that file owns what may be | ||
| * emitted, this one what may be declared, and a byte-identical predicate in both is how a screen | ||
| * and its emitter come to accept different sets — a route that registers cleanly and then emits no | ||
| * age at all. **Zero stays legal at both ends**: `max-age=0` means "revalidate every time" and the | ||
| * framework's own defaults declare it — `PRIVATE_CACHE`, `defaultCache`'s anonymous hint and the | ||
| * CLI's authorized-object hint all carry `maxAgeSeconds: 0`, so a floor of 1 would refuse the | ||
| * framework at its own boot. | ||
| * | ||
| * Called once per route by `createRouter`, which is the one way a `Route` becomes matchable — so | ||
| * every hint an app can serve has been through here, including the ones `@ultimat3/cli` mints for | ||
| * favicons, dev assets and storage objects. | ||
| */ | ||
| export const assertRouteCache = ( | ||
| hint: CacheHint | undefined, | ||
| route: string, | ||
| path: string, | ||
| ): void => { | ||
| if (hint === undefined) return; | ||
| for (const field of DELTA_SECONDS_FIELDS) { | ||
| const value = hint[field]; | ||
| if (value === undefined || isDeltaSeconds(value)) continue; | ||
| throw routeCacheInvalid(route, path, field, value); | ||
| } | ||
| }; |
+53
-0
@@ -22,2 +22,37 @@ # @ultimat3/http | ||
| - **Every numeric knob `defineHttpConfig` resolves is screened, `As of 2026-08-26`** — `port`, | ||
| `bodyLimitBytes`, `requestTimeoutMs`, `maxInflight`, `drainTimeoutMs` and `trustedProxyHops`, | ||
| each a whole number in its own domain or `X_CONFIG_INVALID` (core's code, borrowed as | ||
| `@ultimat3/auth` borrows it). Measured with `NaN`, which is what `Number(process.env.…)` answers | ||
| for an unset variable: `total > NaN` is false so the body cap stopped capping and the whole | ||
| payload was buffered; `NaN <= 0` is false so a deadline armed and `setTimeout(fn, NaN)` is 1ms, | ||
| which 504s every request; `ceiling > 0` is false so `admit` shed nothing; and | ||
| `Math.max(0, Math.floor(NaN))` is `NaN`, so `trustProxy: true` silently trusted no hop and every | ||
| caller's ip became the proxy's. `Math.max(0, …)` was the guard for the last of those — a clamp is | ||
| not a validator, and this package relied on one. `webhook-verify.ts` screens its own two | ||
| (`toleranceMs`, `maxBytes`) and `rate-limit.ts` its `maxKeys`; the helper names carry `Finite` | ||
| (`assertFiniteCount`, `assertFiniteKeyCap`, `assertFiniteBodyLimit`) because | ||
| `bun run finite-bounds` recognises a repair by the shape of the CALL — spelled `count`, all five | ||
| config options read as unchecked to the ratchet while every one was screened. `maxBytes` is | ||
| refused HERE as well as inside `readWithinLimit`: that one is a file away and its `fix:` names | ||
| core's reader rather than the option the caller wrote. | ||
| **The FLOOR is per option, because only the caller knows what zero means** (`As of 2026-08-26`). | ||
| `requestTimeoutMs: 0` is "no deadline" and `maxInflight: 0` is "never shed" — decisions the code | ||
| reads — so those two floor at 0; `trustedProxyHops` floors at **1**, and it shipped for one day | ||
| screened at 0. `forwardedElement` answers `undefined` for `hops < 1`, so | ||
| `{ trustProxy: true, trustedProxyHops: 0 }` was byte-for-byte the failure the screen's own comment | ||
| names: `clientAddress` falls back to the socket, one rate-limit bucket for everything behind the | ||
| ingress, `x-forwarded-proto` untrusted and HSTS never emitted. `-1` and `NaN` were refused for | ||
| producing exactly that state and `0` was accepted into it. `resolveTrustedProxyHops` owns both | ||
| refusals — unset is `X_TRUST_PROXY_UNSET`, out of domain is `X_CONFIG_INVALID` — and there is no | ||
| `?? 0` behind it, because a default of zero reopens the same hole from the other side. | ||
| **`MAX_PROXY_HOPS` is EXPORTED, `As of 2026-08-26`**, and that is the point of it. It was module | ||
| private, so `@ultimat3/cli`'s `trustedHopsFromEnv` — which screens the same setting arriving as | ||
| `TRUSTED_PROXY_HOPS` — restated the literal, and one setting came to have two ceilings: that end | ||
| said 16 while this one said 64, so a deployment behind 20 hops was accepted by the library and | ||
| refused at boot. `cli` is tier 5 and this is tier 2, so the import is downward and legal. The | ||
| number lives here because this is where the setting is. | ||
| - Route `meta.auth` is required. Never default a route to public. | ||
@@ -546,2 +581,19 @@ - **An app declares its half of `HttpConfig` through `configureHttp()`, and the boot lays its own | ||
| point. `factsOf` therefore reads a borrowed code's title off the error itself, never the map. | ||
| - **A `cache-control` age is delta-seconds or it is DROPPED, never emitted** (`As of 2026-08-26`). | ||
| `finiteDeltaSeconds` in `response.ts` — `Number.isSafeInteger && >= 0`, per field. `max-age=NaN` | ||
| is not a shorter age, it is an unparseable directive a conforming cache IGNORES, so the response | ||
| fell back to heuristic caching rather than to the declared age. TOTAL, never a throw: this is the | ||
| response path, and a bad cache hint must not become a 500. Every fallback is the SHORTER | ||
| direction — `max-age` to 0, `s-maxage` and `stale-while-revalidate` omitted — so nothing here can | ||
| lengthen an age the caller did not ask for. `http.cache_hint_not_delta_seconds` names the field. | ||
| **The boot-time half is `route-cache.ts`, `As of 2026-08-26`** — the layered form: refuse where | ||
| the value is WRITTEN, be total where it is USED. `createRouter` screens `Route.cache`'s three | ||
| delta-seconds fields per route and throws `X_CONFIG_INVALID` naming the route and the key, so | ||
| `cache: { maxAgeSeconds: Number(process.env.CACHE_AGE) }` fails at boot instead of registering | ||
| cleanly and surfacing as one warn line per request, forever. The two screens must accept exactly | ||
| the same set: this one decides what may be DECLARED, `finiteDeltaSeconds` what may be WRITTEN, | ||
| and a value one accepts and the other drops is a silent hole between them. **Zero stays legal at | ||
| both ends** — `max-age=0` is "revalidate every time" and `PRIVATE_CACHE`, `defaultCache`'s | ||
| anonymous hint and the CLI's authorized-object hint all declare it. `ctx.cache` is NOT screened | ||
| and must not be: it is app-set per request at runtime, which is the total side by definition. | ||
| - Tests must not touch the network — the preload seals `fetch`. Socket tests live in | ||
@@ -569,2 +621,3 @@ `e2e/` and run with `bun test packages/http/e2e`, sealed: `start()` calls core's | ||
| | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor | | ||
| | `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `createRouter`; the response path's `finiteDeltaSeconds` is the total half of the same rule | | ||
| | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` | | ||
@@ -571,0 +624,0 @@ | `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` | |
+5
-5
| { | ||
| "name": "@ultimat3/http", | ||
| "version": "16.0.0", | ||
| "version": "17.0.0", | ||
| "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "16.0.0", | ||
| "@ultimat3/i18n": "16.0.0", | ||
| "@ultimat3/schema": "16.0.0", | ||
| "@ultimat3/time": "16.0.0" | ||
| "@ultimat3/core": "17.0.0", | ||
| "@ultimat3/i18n": "17.0.0", | ||
| "@ultimat3/schema": "17.0.0", | ||
| "@ultimat3/time": "17.0.0" | ||
| } | ||
| } |
+115
-8
@@ -9,3 +9,3 @@ // The resolver every HTTP config goes through, so a value is either a locked default or an | ||
| import { type CsrfConfig, DEFAULT_CSRF } from './csrf'; | ||
| import { trustProxyUnset } from './errors'; | ||
| import { httpCountInvalid, trustProxyUnset } from './errors'; | ||
| import { | ||
@@ -125,2 +125,74 @@ DEFAULT_LOCALE_CONFIG, | ||
| /** | ||
| * A whole, in-range count, or the refusal that names it. | ||
| * | ||
| * `Number.isSafeInteger` and not `Number.isFinite`: these are byte counts, millisecond budgets and | ||
| * request ceilings, and above 2^53 a double cannot name its own successor — the same rule | ||
| * `@ultimat3/schema` states for an integer at the wire boundary. The `Finite` in the name is | ||
| * load-bearing: `bun run finite-bounds` recognises a repair by the shape of the CALL, so a screen | ||
| * named `count` left every option below reading as unchecked. | ||
| * | ||
| * `min` is the CALLER's, exactly as it is on `@ultimat3/core`'s `finiteCount`, because only the | ||
| * caller knows what zero means: `requestTimeoutMs: 0` is "no deadline" and `maxInflight: 0` is | ||
| * "never shed", both decisions the code reads, while `trustedProxyHops: 0` is a proxy trusted for | ||
| * nothing — the state the whole declaration exists to refuse. A helper that picked one bound would | ||
| * be wrong at half the call sites, and a second helper for "positive" would be the copy. | ||
| */ | ||
| const assertFiniteCount = ( | ||
| name: string, | ||
| value: number, | ||
| max: number, | ||
| expected: string, | ||
| example: string, | ||
| min: 0 | 1 = 0, | ||
| ): number => { | ||
| if (!Number.isSafeInteger(value) || value < min || value > max) { | ||
| throw httpCountInvalid(name, value, expected, example); | ||
| } | ||
| return value; | ||
| }; | ||
| const MAX_PORT = 65_535; | ||
| /** | ||
| * Nobody has 64 proxies in front of one process; a bigger number is a typo, not a topology. | ||
| * | ||
| * EXPORTED, and that is the point of it: `@ultimat3/cli`'s `trustedHopsFromEnv` screens the same | ||
| * setting from `TRUSTED_PROXY_HOPS` and had to restate the literal, which is how one setting came | ||
| * to have two ceilings — that end said 16 while this one said 64, so a deployment behind 20 hops | ||
| * was accepted by the library and refused at boot. `cli` is tier 5 and this is tier 2, so the | ||
| * import is downward and legal; the number lives here because this is where the setting is. | ||
| */ | ||
| export const MAX_PROXY_HOPS = 64; | ||
| /** | ||
| * Which `x-forwarded-for` entry is the caller, or the refusal that says the declaration is not one. | ||
| * | ||
| * Screened, not clamped. `Math.max(0, Math.floor(x))` turned `-1` into `0` and `NaN` into `NaN`, | ||
| * and BOTH mean "trust nothing" to `forwardedElement` — so the one declaration saying which entry | ||
| * the caller wrote silently stopped being made, and every request's client ip became the proxy's | ||
| * own. One rate-limit bucket for everything behind the ingress, no word said. | ||
| * | ||
| * **`0` is that same state and is refused with them**, `As of 2026-08-26`. `forwardedElement` | ||
| * answers `undefined` for `hops < 1`, so `{ trustProxy: true, trustedProxyHops: 0 }` produced | ||
| * exactly the failure the screen was written for while the screen accepted it. One is the smallest | ||
| * topology `trustProxy: true` can describe. | ||
| * | ||
| * There is no `?? 0` fallback, and that is the point: an undeclared count is `trustProxyUnset()`, | ||
| * because "trust the header" and "know which entry of it" are one declaration and half of it is a | ||
| * header the caller writes. A default of zero would reopen the same hole from the other side. | ||
| */ | ||
| const resolveTrustedProxyHops = (trustProxy: boolean, declared: number | undefined): number => { | ||
| if (!trustProxy) return 0; | ||
| if (declared === undefined) throw trustProxyUnset(); | ||
| return assertFiniteCount( | ||
| 'trustedProxyHops', | ||
| declared, | ||
| MAX_PROXY_HOPS, | ||
| 'the whole number of proxies that append to x-forwarded-for, at least 1', | ||
| 'trustProxy: true, trustedProxyHops: 1', | ||
| 1, | ||
| ); | ||
| }; | ||
| export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => { | ||
@@ -140,3 +212,3 @@ // `ULTIMATE_ENV` is the framework's one environment key and `NODE_ENV` is only its fallback, so | ||
| // one declaration, and half of it is a header the caller writes. | ||
| if (trustProxy && input.trustedProxyHops === undefined) throw trustProxyUnset(); | ||
| const trustedProxyHops = resolveTrustedProxyHops(trustProxy, input.trustedProxyHops); | ||
| const csp = { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp }; | ||
@@ -148,3 +220,11 @@ // Beside `assertCorsConfig`, and for its reason: a merged value is the only one that can be | ||
| return { | ||
| port: input.port ?? Number.parseInt(env('PORT') ?? '3000', 10), | ||
| // `Number.parseInt(env('PORT'), 10)` is `NaN` for `PORT=web`, and a config that carries NaN | ||
| // into `Bun.serve` binds a port nobody asked for. | ||
| port: assertFiniteCount( | ||
| 'port', | ||
| input.port ?? Number.parseInt(env('PORT') ?? '3000', 10), | ||
| MAX_PORT, | ||
| 'a whole port number from 0 to 65535, where 0 asks the OS for a free one', | ||
| 'port: 3000', | ||
| ), | ||
| hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0', | ||
@@ -157,9 +237,36 @@ basePath: input.basePath ?? '/', | ||
| trustProxy, | ||
| trustedProxyHops: trustProxy ? Math.max(0, Math.floor(input.trustedProxyHops ?? 0)) : 0, | ||
| bodyLimitBytes: input.bodyLimitBytes ?? 1_048_576, | ||
| trustedProxyHops, | ||
| bodyLimitBytes: assertFiniteCount( | ||
| 'bodyLimitBytes', | ||
| input.bodyLimitBytes ?? 1_048_576, | ||
| Number.MAX_SAFE_INTEGER, | ||
| 'a whole number of bytes', | ||
| 'bodyLimitBytes: 1_048_576', | ||
| ), | ||
| // 30s: longer than any request a browser waits out, shorter than the 15s drain budget times | ||
| // two, so a rolling restart cannot be held open by work started just before SIGTERM. | ||
| requestTimeoutMs: input.requestTimeoutMs ?? 30_000, | ||
| maxInflight: input.maxInflight ?? 1_000, | ||
| drainTimeoutMs: input.drainTimeoutMs ?? null, | ||
| requestTimeoutMs: assertFiniteCount( | ||
| 'requestTimeoutMs', | ||
| input.requestTimeoutMs ?? 30_000, | ||
| Number.MAX_SAFE_INTEGER, | ||
| 'a whole number of milliseconds, where 0 means no deadline', | ||
| 'requestTimeoutMs: 30_000', | ||
| ), | ||
| maxInflight: assertFiniteCount( | ||
| 'maxInflight', | ||
| input.maxInflight ?? 1_000, | ||
| Number.MAX_SAFE_INTEGER, | ||
| 'a whole number of requests, where 0 means never shed', | ||
| 'maxInflight: 1_000', | ||
| ), | ||
| drainTimeoutMs: | ||
| input.drainTimeoutMs === undefined || input.drainTimeoutMs === null | ||
| ? null | ||
| : assertFiniteCount( | ||
| 'drainTimeoutMs', | ||
| input.drainTimeoutMs, | ||
| Number.MAX_SAFE_INTEGER, | ||
| 'a whole number of milliseconds, or null for the lifecycle default', | ||
| 'drainTimeoutMs: 15_000', | ||
| ), | ||
| locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale }, | ||
@@ -166,0 +273,0 @@ tz: { ...DEFAULT_TZ_CONFIG, ...input.tz }, |
+11
-0
@@ -41,2 +41,6 @@ // The one place a framework error code becomes an HTTP status. A table, not a | ||
| X_CORS_CONFIG_INVALID: 500, | ||
| // Core's code, borrowed by `defineHttpConfig` for a numeric knob that is not a count. Same | ||
| // construction-time shelf as the row above, and it needs a row for the same reason: this table | ||
| // is closed over every code the package can throw, borrowed ones included. | ||
| X_CONFIG_INVALID: 500, | ||
| X_CSP_DIRECTIVE_INVALID: 500, | ||
@@ -275,2 +279,9 @@ // Thrown while the server is being constructed, so no request is ever answered with it either. | ||
| X_LOCALE_UNSUPPORTED: 400, | ||
| // @ultimat3/core (declared beside `assertLocale`; `@ultimat3/time` owned it until 16.x) — the | ||
| // sibling of the row above, and strictly more the caller's fault: not a tag at all. It was | ||
| // pinned as unable to reach a request because the `locale` stage negotiates and never throws, | ||
| // which is true of that stage and irrelevant to `?locale=`, a path segment or an action input | ||
| // reaching `formatDate` / `formatMoney` / `describeCron`. Those raised it and answered 500, | ||
| // paging the on-call for a string the caller typed. | ||
| X_LOCALE_INVALID: 400, | ||
| // @ultimat3/money — a well-formed code this process carries no row for. The currency table is | ||
@@ -277,0 +288,0 @@ // OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any |
+52
-0
@@ -59,2 +59,8 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a | ||
| 'X_DRAINING', | ||
| // Core's, and the code `app.config.ts is invalid` already means — borrowed for the numeric knobs | ||
| // `defineHttpConfig` screens, the same way `@ultimat3/auth` borrows it for a `defineAuth` | ||
| // declaration it cannot honour. A per-knob code of our own would be a second answer to a | ||
| // question core has already answered, and the throw happens while config resolves, so no request | ||
| // is ever answered with it. | ||
| 'X_CONFIG_INVALID', | ||
| ] as const; | ||
@@ -339,2 +345,48 @@ | ||
| /** | ||
| * A numeric knob that is not a count, refused where `app.config.ts` still names it. | ||
| * | ||
| * Every one of these arrives as `Number(process.env.X)` as often as a literal, and `NaN` is not | ||
| * nullish — so `??` passes it through, `Math.max`/`Math.floor` propagate it, and every comparison | ||
| * downstream then answers FALSE. What that produces is never a wrong number, it is the guard | ||
| * switching itself off: `bodyLimitBytes` stops capping the body, `maxInflight` stops shedding, | ||
| * `trustedProxyHops` stops trusting the proxy it was set for, and `requestTimeoutMs` arms a | ||
| * `setTimeout(fn, NaN)` — which is 1ms — so every request 504s. `Math.max(1, x)` is not a | ||
| * validator, which is exactly what `trustedProxyHops` was using. | ||
| */ | ||
| export const httpCountInvalid = ( | ||
| name: string, | ||
| value: number, | ||
| expected: string, | ||
| example: string, | ||
| ): HttpError => | ||
| new HttpError({ | ||
| code: 'X_CONFIG_INVALID', | ||
| cause: `http.${name} is ${String(value)}; it must be ${expected}, and NaN is what Number(process.env.…) answers for an unset variable — every comparison against it is false, so the limit it names stops being enforced rather than being enforced wrongly`, | ||
| fix: `set ${name} to ${expected} in configureHttp({ ${example} }), and parse an environment value before you pass it: Number.parseInt(process.env.${name.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()} ?? '', 10) is NaN when the variable is unset`, | ||
| meta: { option: name, value: String(value) }, | ||
| }); | ||
| /** | ||
| * A route DECLARING a cache age that a cache cannot read. `X_CONFIG_INVALID` and not a code of its | ||
| * own, for the reason the borrowed list gives: this is a declaration the framework cannot honour, | ||
| * and it is thrown while the route table is built, so no request is ever answered with it. | ||
| * | ||
| * Thrown here because `cacheControl` cannot throw: the response path is total by design — it drops | ||
| * the field and logs — so without this the mistake is a log line once per request, forever, while | ||
| * the response falls back to HEURISTIC caching, the one behaviour no `CacheHint` ever asked for. | ||
| */ | ||
| export const routeCacheInvalid = ( | ||
| route: string, | ||
| path: string, | ||
| field: string, | ||
| value: number, | ||
| ): HttpError => | ||
| new HttpError({ | ||
| code: 'X_CONFIG_INVALID', | ||
| cause: `route ${route} (${path}) declares cache.${field} = ${String(value)}, and cache-control delta-seconds is 1*DIGIT — a fraction, a negative and NaN are directives a conforming cache IGNORES, so the response would fall back to heuristic caching instead of the age declared here`, | ||
| fix: `in the route file declaring ${route}, set meta.cache.${field} to a whole number of seconds, 0 or more — 0 is legal and means "revalidate every time". If it comes from the environment, give it a default before it reaches the route: Number(process.env.X) is NaN when the variable is unset and ?? does not catch that, because NaN is not nullish`, | ||
| meta: { route, path, option: `cache.${field}`, value: String(value) }, | ||
| }); | ||
| /** | ||
| * SIGTERM has run the `accept` phase: `readyz` is already 503 and the socket is closing, but a | ||
@@ -341,0 +393,0 @@ * connection the load balancer had not yet stopped using still arrives. Answering it with a |
+2
-1
@@ -19,3 +19,3 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not | ||
| export type { HttpConfig, HttpConfigInput } from './config'; | ||
| export { defineHttpConfig, stripBasePath } from './config'; | ||
| export { defineHttpConfig, MAX_PROXY_HOPS, stripBasePath } from './config'; | ||
| export type { ActorView, RequestContext, RequestContextInit } from './context'; | ||
@@ -86,2 +86,3 @@ export { | ||
| requestTimedOut, | ||
| routeCacheInvalid, | ||
| routeConflict, | ||
@@ -88,0 +89,0 @@ routeNotFound, |
+18
-1
@@ -6,2 +6,3 @@ // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in | ||
| import { type Clock, systemClock } from '@ultimat3/core'; | ||
| import { httpCountInvalid } from './errors'; | ||
| import { | ||
@@ -236,2 +237,13 @@ rateLimited, | ||
| /** A whole positive count, or the config refusal that names it. */ | ||
| const assertFiniteKeyCap = (option: string, value: number): number => { | ||
| if (Number.isSafeInteger(value) && value >= 1) return value; | ||
| throw httpCountInvalid( | ||
| `rate limit store ${option}`, | ||
| value, | ||
| 'a whole number of at least 1', | ||
| `maxKeys: ${DEFAULT_MAX_RATE_LIMIT_KEYS}`, | ||
| ); | ||
| }; | ||
| /** | ||
@@ -251,3 +263,8 @@ * Default driver: correct for one process, which is exactly dev and tests. | ||
| ): MemoryRateLimitStore => { | ||
| const maxKeys = Math.max(1, Math.floor(options.maxKeys ?? DEFAULT_MAX_RATE_LIMIT_KEYS)); | ||
| // Screened, not clamped. `Math.max(1, Math.floor(x))` was the guard, and `Math.floor(NaN)` is | ||
| // `NaN`: `buckets.size > maxKeys` in `take` is then false so the sweep never runs, and | ||
| // `buckets.size <= maxKeys` in the sweep is false so it would evict nothing if it did. The cap | ||
| // is the only thing between a rotating-address scan and this process's memory, and the keys are | ||
| // addresses the caller chooses — a clamp that quietly answers `NaN` removes it. | ||
| const maxKeys = assertFiniteKeyCap('maxKeys', options.maxKeys ?? DEFAULT_MAX_RATE_LIMIT_KEYS); | ||
| const evictTo = Math.max(1, Math.floor(maxKeys * 0.9)); | ||
@@ -254,0 +271,0 @@ const buckets = new Map<string, BucketState>(); |
+53
-4
| // Response constructors. Every response in the framework is built here so that | ||
| // content types, charsets and cache semantics are decided once instead of per route. | ||
| import { logger } from '@ultimat3/core'; | ||
| import { TIMEZONE_HEADER } from '@ultimat3/time'; | ||
@@ -119,13 +120,61 @@ import { toProblem } from './error-facts'; | ||
| /** A year, the only age at which `immutable` says anything a shorter one does not. */ | ||
| const IMMUTABLE_MAX_AGE_SECONDS = 31_536_000; | ||
| /** | ||
| * RFC-9111 delta-seconds is `1*DIGIT`, so `max-age=NaN` is not a shorter age or a longer one — it | ||
| * is an unparseable directive, and a conforming cache IGNORES a directive it cannot parse. The | ||
| * response then falls back to HEURISTIC caching (a fraction of `Last-Modified`'s age), which is | ||
| * the one behaviour no `CacheHint` ever asked for and the one nothing downstream can detect. | ||
| * `??` guards nullish and `NaN` is not nullish, so an age computed from a timestamp difference or | ||
| * read out of an env value arrives here intact; a fraction (`ms / 1000`) and a negative (a clock | ||
| * that went backwards) are the same unparseable token. | ||
| * | ||
| * The name carries `finite` deliberately: `bun run finite-bounds` recognises a repair by the shape | ||
| * of the CALL, so this screen spelled `deltaSeconds` read as no screen at all. | ||
| * | ||
| * `isDeltaSeconds` is EXPORTED and has exactly two readers, which is the point of it: this one | ||
| * decides what may be WRITTEN, and `route-cache.ts` decides what may be DECLARED. A byte-identical | ||
| * predicate in both files is how the two drift, and a value one accepts and the other drops is a | ||
| * silent hole in the middle — a route that registers cleanly and then emits no age. | ||
| * | ||
| * TOTAL, never a throw: this is the response path, where refusing turns a bad cache hint into a | ||
| * 500. A dropped age is always the SAFER direction — no `s-maxage` means a shared cache falls back | ||
| * to `max-age`, and a `max-age` of 0 means revalidate — so nothing here can lengthen an age the | ||
| * caller did not ask for. The warning is what keeps it from being silent; the fix belongs at the | ||
| * declaration, and `field` names which one. | ||
| */ | ||
| export const isDeltaSeconds = (value: number): boolean => Number.isSafeInteger(value) && value >= 0; | ||
| const finiteDeltaSeconds = (field: string, value: number): number | undefined => { | ||
| if (isDeltaSeconds(value)) return value; | ||
| logger.warn('http.cache_hint_not_delta_seconds', { field, value: String(value) }); | ||
| return undefined; | ||
| }; | ||
| export const cacheControl = (hint: CacheHint): string => { | ||
| if (hint.mode === 'no-store') return 'no-store'; | ||
| if (hint.mode === 'immutable') { | ||
| return `public, max-age=${hint.maxAgeSeconds ?? 31_536_000}, immutable`; | ||
| const age = finiteDeltaSeconds( | ||
| 'maxAgeSeconds', | ||
| hint.maxAgeSeconds ?? IMMUTABLE_MAX_AGE_SECONDS, | ||
| ); | ||
| // 0 rather than the year: a declared age that is not a number is not evidence for the longest | ||
| // age in the file, and an over-long `immutable` is the one cache mistake a purge cannot undo. | ||
| return `public, max-age=${String(age ?? 0)}, immutable`; | ||
| } | ||
| const parts = [hint.mode, `max-age=${hint.maxAgeSeconds ?? 0}`]; | ||
| const parts = [ | ||
| hint.mode, | ||
| `max-age=${String(finiteDeltaSeconds('maxAgeSeconds', hint.maxAgeSeconds ?? 0) ?? 0)}`, | ||
| ]; | ||
| if (hint.mode === 'public' && hint.sMaxAgeSeconds !== undefined) { | ||
| parts.push(`s-maxage=${hint.sMaxAgeSeconds}`); | ||
| const shared = finiteDeltaSeconds('sMaxAgeSeconds', hint.sMaxAgeSeconds); | ||
| if (shared !== undefined) parts.push(`s-maxage=${String(shared)}`); | ||
| } | ||
| if (hint.staleWhileRevalidateSeconds !== undefined) { | ||
| parts.push(`stale-while-revalidate=${hint.staleWhileRevalidateSeconds}`); | ||
| const stale = finiteDeltaSeconds( | ||
| 'staleWhileRevalidateSeconds', | ||
| hint.staleWhileRevalidateSeconds, | ||
| ); | ||
| if (stale !== undefined) parts.push(`stale-while-revalidate=${String(stale)}`); | ||
| } | ||
@@ -132,0 +181,0 @@ return parts.join(', '); |
+5
-0
@@ -19,2 +19,3 @@ // The route table. A segment trie, not a regex list, so matching cost is bounded by | ||
| import type { CacheHint } from './response'; | ||
| import { assertRouteCache } from './route-cache'; | ||
| import type { Schema } from './validate'; | ||
@@ -133,2 +134,6 @@ | ||
| for (const route of routes) { | ||
| // Before the trie is touched: a hint that cannot be emitted is refused where it was WRITTEN, | ||
| // not on the response path — `cacheControl` is total there by design, so the alternative is a | ||
| // log line once per request and a response that quietly falls back to heuristic caching. | ||
| assertRouteCache(route.meta.cache, route.meta.name, route.path); | ||
| let current = root; | ||
@@ -135,0 +140,0 @@ const segments = segmentsOf(route.path); |
@@ -25,3 +25,8 @@ // The inbound half of the framework's webhook mechanism: prove a request was signed by the holder | ||
| } from '@ultimat3/core'; | ||
| import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './errors'; | ||
| import { | ||
| bodyInvalid, | ||
| httpCountInvalid, | ||
| webhookSignatureInvalid, | ||
| webhookSignatureStale, | ||
| } from './errors'; | ||
@@ -37,2 +42,19 @@ /** | ||
| /** | ||
| * The screen above `toleranceMs`, kept beside the constant it defaults to. | ||
| * | ||
| * The `Finite` in the name is load-bearing: `bun run finite-bounds` recognises a repair by the | ||
| * shape of the CALL, so a screen named for what it guards is invisible to the ratchet that would | ||
| * otherwise catch the next one of these. | ||
| */ | ||
| const assertFiniteToleranceMs = (toleranceMs: number): number => { | ||
| if (Number.isSafeInteger(toleranceMs) && toleranceMs >= 0) return toleranceMs; | ||
| throw httpCountInvalid( | ||
| 'webhook toleranceMs', | ||
| toleranceMs, | ||
| 'a whole number of milliseconds, zero or more', | ||
| 'toleranceMs: 300_000', | ||
| ); | ||
| }; | ||
| /** | ||
| * Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this | ||
@@ -44,2 +66,19 @@ * function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a | ||
| /** | ||
| * The same screen above `maxBytes`, refused HERE and not only where it lands. | ||
| * | ||
| * `readWithinLimit` refuses a non-finite limit as well, and that is one file away: its `fix:` names | ||
| * core's reader, while the edit the caller has to make is the `maxBytes` written on this call. Zero | ||
| * is excluded on purpose — a cap of nothing refuses every delivery a sender can make. | ||
| */ | ||
| const assertFiniteBodyLimit = (maxBytes: number): number => { | ||
| if (Number.isSafeInteger(maxBytes) && maxBytes >= 1) return maxBytes; | ||
| throw httpCountInvalid( | ||
| 'webhook maxBytes', | ||
| maxBytes, | ||
| 'a whole number of bytes, at least 1', | ||
| `maxBytes: ${DEFAULT_WEBHOOK_BODY_LIMIT}`, | ||
| ); | ||
| }; | ||
| export interface WebhookVerifyOptions { | ||
@@ -103,3 +142,3 @@ /** The shared secret for THIS sender. Never logged, never rendered into a refusal. */ | ||
| const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT; | ||
| const maxBytes = assertFiniteBodyLimit(options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT); | ||
| // Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that | ||
@@ -130,3 +169,7 @@ // announces no length must not be able to make this handler hold an unbounded payload before the | ||
| const signedAtMs = signature.timestampSeconds * 1_000; | ||
| const toleranceMs = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS; | ||
| // The tolerance IS the replay window, so it is screened before it is compared against: `skewMs > | ||
| // NaN` is false, which does not widen the window — it removes it, and a webhook captured a year | ||
| // ago verifies forever with every other check passing. Refused as the config it is, and not by | ||
| // the sender's error: nothing the caller sends can fix it. | ||
| const toleranceMs = assertFiniteToleranceMs(options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS); | ||
| const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs); | ||
@@ -133,0 +176,0 @@ // Both directions: a sender whose clock runs ahead is the same replay window pointed the other |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
400443
5.77%43
2.38%6287
5.36%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated