@ultimat3/http 16.0.0 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -0
- package/package.json +5 -5
- package/src/config.ts +115 -8
- package/src/error-map.ts +11 -0
- package/src/errors.ts +52 -0
- package/src/index.ts +2 -1
- package/src/rate-limit.ts +18 -1
- package/src/response.ts +53 -4
- package/src/route-cache.ts +49 -0
- package/src/router.ts +5 -0
- package/src/webhook-verify.ts +46 -3
package/CLAUDE.md
CHANGED
|
@@ -19,6 +19,41 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
19
19
|
|
|
20
20
|
## Rules
|
|
21
21
|
|
|
22
|
+
- **Every numeric knob `defineHttpConfig` resolves is screened, `As of 2026-08-26`** — `port`,
|
|
23
|
+
`bodyLimitBytes`, `requestTimeoutMs`, `maxInflight`, `drainTimeoutMs` and `trustedProxyHops`,
|
|
24
|
+
each a whole number in its own domain or `X_CONFIG_INVALID` (core's code, borrowed as
|
|
25
|
+
`@ultimat3/auth` borrows it). Measured with `NaN`, which is what `Number(process.env.…)` answers
|
|
26
|
+
for an unset variable: `total > NaN` is false so the body cap stopped capping and the whole
|
|
27
|
+
payload was buffered; `NaN <= 0` is false so a deadline armed and `setTimeout(fn, NaN)` is 1ms,
|
|
28
|
+
which 504s every request; `ceiling > 0` is false so `admit` shed nothing; and
|
|
29
|
+
`Math.max(0, Math.floor(NaN))` is `NaN`, so `trustProxy: true` silently trusted no hop and every
|
|
30
|
+
caller's ip became the proxy's. `Math.max(0, …)` was the guard for the last of those — a clamp is
|
|
31
|
+
not a validator, and this package relied on one. `webhook-verify.ts` screens its own two
|
|
32
|
+
(`toleranceMs`, `maxBytes`) and `rate-limit.ts` its `maxKeys`; the helper names carry `Finite`
|
|
33
|
+
(`assertFiniteCount`, `assertFiniteKeyCap`, `assertFiniteBodyLimit`) because
|
|
34
|
+
`bun run finite-bounds` recognises a repair by the shape of the CALL — spelled `count`, all five
|
|
35
|
+
config options read as unchecked to the ratchet while every one was screened. `maxBytes` is
|
|
36
|
+
refused HERE as well as inside `readWithinLimit`: that one is a file away and its `fix:` names
|
|
37
|
+
core's reader rather than the option the caller wrote.
|
|
38
|
+
|
|
39
|
+
**The FLOOR is per option, because only the caller knows what zero means** (`As of 2026-08-26`).
|
|
40
|
+
`requestTimeoutMs: 0` is "no deadline" and `maxInflight: 0` is "never shed" — decisions the code
|
|
41
|
+
reads — so those two floor at 0; `trustedProxyHops` floors at **1**, and it shipped for one day
|
|
42
|
+
screened at 0. `forwardedElement` answers `undefined` for `hops < 1`, so
|
|
43
|
+
`{ trustProxy: true, trustedProxyHops: 0 }` was byte-for-byte the failure the screen's own comment
|
|
44
|
+
names: `clientAddress` falls back to the socket, one rate-limit bucket for everything behind the
|
|
45
|
+
ingress, `x-forwarded-proto` untrusted and HSTS never emitted. `-1` and `NaN` were refused for
|
|
46
|
+
producing exactly that state and `0` was accepted into it. `resolveTrustedProxyHops` owns both
|
|
47
|
+
refusals — unset is `X_TRUST_PROXY_UNSET`, out of domain is `X_CONFIG_INVALID` — and there is no
|
|
48
|
+
`?? 0` behind it, because a default of zero reopens the same hole from the other side.
|
|
49
|
+
|
|
50
|
+
**`MAX_PROXY_HOPS` is EXPORTED, `As of 2026-08-26`**, and that is the point of it. It was module
|
|
51
|
+
private, so `@ultimat3/cli`'s `trustedHopsFromEnv` — which screens the same setting arriving as
|
|
52
|
+
`TRUSTED_PROXY_HOPS` — restated the literal, and one setting came to have two ceilings: that end
|
|
53
|
+
said 16 while this one said 64, so a deployment behind 20 hops was accepted by the library and
|
|
54
|
+
refused at boot. `cli` is tier 5 and this is tier 2, so the import is downward and legal. The
|
|
55
|
+
number lives here because this is where the setting is.
|
|
56
|
+
|
|
22
57
|
- Route `meta.auth` is required. Never default a route to public.
|
|
23
58
|
- **An app declares its half of `HttpConfig` through `configureHttp()`, and the boot lays its own
|
|
24
59
|
facts over it** (`As of 2026-08-24`). Until 12.0.0 the entire tuning surface was **unreachable
|
|
@@ -542,6 +577,23 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
542
577
|
only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
|
|
543
578
|
unguarded — declaring a borrowed one throws `X_ERROR_CODE_DUPLICATE` at import, which is the
|
|
544
579
|
point. `factsOf` therefore reads a borrowed code's title off the error itself, never the map.
|
|
580
|
+
- **A `cache-control` age is delta-seconds or it is DROPPED, never emitted** (`As of 2026-08-26`).
|
|
581
|
+
`finiteDeltaSeconds` in `response.ts` — `Number.isSafeInteger && >= 0`, per field. `max-age=NaN`
|
|
582
|
+
is not a shorter age, it is an unparseable directive a conforming cache IGNORES, so the response
|
|
583
|
+
fell back to heuristic caching rather than to the declared age. TOTAL, never a throw: this is the
|
|
584
|
+
response path, and a bad cache hint must not become a 500. Every fallback is the SHORTER
|
|
585
|
+
direction — `max-age` to 0, `s-maxage` and `stale-while-revalidate` omitted — so nothing here can
|
|
586
|
+
lengthen an age the caller did not ask for. `http.cache_hint_not_delta_seconds` names the field.
|
|
587
|
+
**The boot-time half is `route-cache.ts`, `As of 2026-08-26`** — the layered form: refuse where
|
|
588
|
+
the value is WRITTEN, be total where it is USED. `createRouter` screens `Route.cache`'s three
|
|
589
|
+
delta-seconds fields per route and throws `X_CONFIG_INVALID` naming the route and the key, so
|
|
590
|
+
`cache: { maxAgeSeconds: Number(process.env.CACHE_AGE) }` fails at boot instead of registering
|
|
591
|
+
cleanly and surfacing as one warn line per request, forever. The two screens must accept exactly
|
|
592
|
+
the same set: this one decides what may be DECLARED, `finiteDeltaSeconds` what may be WRITTEN,
|
|
593
|
+
and a value one accepts and the other drops is a silent hole between them. **Zero stays legal at
|
|
594
|
+
both ends** — `max-age=0` is "revalidate every time" and `PRIVATE_CACHE`, `defaultCache`'s
|
|
595
|
+
anonymous hint and the CLI's authorized-object hint all declare it. `ctx.cache` is NOT screened
|
|
596
|
+
and must not be: it is app-set per request at runtime, which is the total side by definition.
|
|
545
597
|
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in
|
|
546
598
|
`e2e/` and run with `bun test packages/http/e2e`, sealed: `start()` calls core's
|
|
547
599
|
`markListening()`, so the seal treats our own port as self, not egress. Never unseal.
|
|
@@ -564,6 +616,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
564
616
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
565
617
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
566
618
|
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
|
|
619
|
+
| `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 |
|
|
567
620
|
| `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
|
|
568
621
|
| `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
|
|
569
622
|
| `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling; the codes and titles stay there, one registry |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.0.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "17.0.0",
|
|
35
|
+
"@ultimat3/i18n": "17.0.0",
|
|
36
|
+
"@ultimat3/schema": "17.0.0",
|
|
37
|
+
"@ultimat3/time": "17.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/config.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
7
7
|
import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
|
|
8
8
|
import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
|
|
9
|
-
import { trustProxyUnset } from './errors';
|
|
9
|
+
import { httpCountInvalid, trustProxyUnset } from './errors';
|
|
10
10
|
import {
|
|
11
11
|
DEFAULT_LOCALE_CONFIG,
|
|
12
12
|
DEFAULT_TZ_CONFIG,
|
|
@@ -121,6 +121,78 @@ const env = (name: string): string | undefined => {
|
|
|
121
121
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A whole, in-range count, or the refusal that names it.
|
|
126
|
+
*
|
|
127
|
+
* `Number.isSafeInteger` and not `Number.isFinite`: these are byte counts, millisecond budgets and
|
|
128
|
+
* request ceilings, and above 2^53 a double cannot name its own successor — the same rule
|
|
129
|
+
* `@ultimat3/schema` states for an integer at the wire boundary. The `Finite` in the name is
|
|
130
|
+
* load-bearing: `bun run finite-bounds` recognises a repair by the shape of the CALL, so a screen
|
|
131
|
+
* named `count` left every option below reading as unchecked.
|
|
132
|
+
*
|
|
133
|
+
* `min` is the CALLER's, exactly as it is on `@ultimat3/core`'s `finiteCount`, because only the
|
|
134
|
+
* caller knows what zero means: `requestTimeoutMs: 0` is "no deadline" and `maxInflight: 0` is
|
|
135
|
+
* "never shed", both decisions the code reads, while `trustedProxyHops: 0` is a proxy trusted for
|
|
136
|
+
* nothing — the state the whole declaration exists to refuse. A helper that picked one bound would
|
|
137
|
+
* be wrong at half the call sites, and a second helper for "positive" would be the copy.
|
|
138
|
+
*/
|
|
139
|
+
const assertFiniteCount = (
|
|
140
|
+
name: string,
|
|
141
|
+
value: number,
|
|
142
|
+
max: number,
|
|
143
|
+
expected: string,
|
|
144
|
+
example: string,
|
|
145
|
+
min: 0 | 1 = 0,
|
|
146
|
+
): number => {
|
|
147
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
148
|
+
throw httpCountInvalid(name, value, expected, example);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const MAX_PORT = 65_535;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Nobody has 64 proxies in front of one process; a bigger number is a typo, not a topology.
|
|
157
|
+
*
|
|
158
|
+
* EXPORTED, and that is the point of it: `@ultimat3/cli`'s `trustedHopsFromEnv` screens the same
|
|
159
|
+
* setting from `TRUSTED_PROXY_HOPS` and had to restate the literal, which is how one setting came
|
|
160
|
+
* to have two ceilings — that end said 16 while this one said 64, so a deployment behind 20 hops
|
|
161
|
+
* was accepted by the library and refused at boot. `cli` is tier 5 and this is tier 2, so the
|
|
162
|
+
* import is downward and legal; the number lives here because this is where the setting is.
|
|
163
|
+
*/
|
|
164
|
+
export const MAX_PROXY_HOPS = 64;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Which `x-forwarded-for` entry is the caller, or the refusal that says the declaration is not one.
|
|
168
|
+
*
|
|
169
|
+
* Screened, not clamped. `Math.max(0, Math.floor(x))` turned `-1` into `0` and `NaN` into `NaN`,
|
|
170
|
+
* and BOTH mean "trust nothing" to `forwardedElement` — so the one declaration saying which entry
|
|
171
|
+
* the caller wrote silently stopped being made, and every request's client ip became the proxy's
|
|
172
|
+
* own. One rate-limit bucket for everything behind the ingress, no word said.
|
|
173
|
+
*
|
|
174
|
+
* **`0` is that same state and is refused with them**, `As of 2026-08-26`. `forwardedElement`
|
|
175
|
+
* answers `undefined` for `hops < 1`, so `{ trustProxy: true, trustedProxyHops: 0 }` produced
|
|
176
|
+
* exactly the failure the screen was written for while the screen accepted it. One is the smallest
|
|
177
|
+
* topology `trustProxy: true` can describe.
|
|
178
|
+
*
|
|
179
|
+
* There is no `?? 0` fallback, and that is the point: an undeclared count is `trustProxyUnset()`,
|
|
180
|
+
* because "trust the header" and "know which entry of it" are one declaration and half of it is a
|
|
181
|
+
* header the caller writes. A default of zero would reopen the same hole from the other side.
|
|
182
|
+
*/
|
|
183
|
+
const resolveTrustedProxyHops = (trustProxy: boolean, declared: number | undefined): number => {
|
|
184
|
+
if (!trustProxy) return 0;
|
|
185
|
+
if (declared === undefined) throw trustProxyUnset();
|
|
186
|
+
return assertFiniteCount(
|
|
187
|
+
'trustedProxyHops',
|
|
188
|
+
declared,
|
|
189
|
+
MAX_PROXY_HOPS,
|
|
190
|
+
'the whole number of proxies that append to x-forwarded-for, at least 1',
|
|
191
|
+
'trustProxy: true, trustedProxyHops: 1',
|
|
192
|
+
1,
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
124
196
|
export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
125
197
|
// `ULTIMATE_ENV` is the framework's one environment key and `NODE_ENV` is only its fallback, so
|
|
126
198
|
// reading `NODE_ENV` alone made a deployment that declared production the documented way serve
|
|
@@ -135,14 +207,22 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
135
207
|
const trustProxy = input.trustProxy ?? false;
|
|
136
208
|
// Refused here, not on the first request: "trust the header" and "know which entry of it" are
|
|
137
209
|
// one declaration, and half of it is a header the caller writes.
|
|
138
|
-
|
|
210
|
+
const trustedProxyHops = resolveTrustedProxyHops(trustProxy, input.trustedProxyHops);
|
|
139
211
|
const csp = { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp };
|
|
140
212
|
// Beside `assertCorsConfig`, and for its reason: a merged value is the only one that can be
|
|
141
213
|
// judged, and a directive name that is not a token would otherwise be a bare `TypeError` out of
|
|
142
214
|
// the first response's header build — or worse, a second directive nobody declared.
|
|
143
215
|
assertCspExtend(csp.extend);
|
|
144
216
|
return {
|
|
145
|
-
|
|
217
|
+
// `Number.parseInt(env('PORT'), 10)` is `NaN` for `PORT=web`, and a config that carries NaN
|
|
218
|
+
// into `Bun.serve` binds a port nobody asked for.
|
|
219
|
+
port: assertFiniteCount(
|
|
220
|
+
'port',
|
|
221
|
+
input.port ?? Number.parseInt(env('PORT') ?? '3000', 10),
|
|
222
|
+
MAX_PORT,
|
|
223
|
+
'a whole port number from 0 to 65535, where 0 asks the OS for a free one',
|
|
224
|
+
'port: 3000',
|
|
225
|
+
),
|
|
146
226
|
hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0',
|
|
147
227
|
basePath: input.basePath ?? '/',
|
|
148
228
|
buildId: input.buildId ?? env('BUILD_ID') ?? null,
|
|
@@ -150,13 +230,40 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
150
230
|
dev,
|
|
151
231
|
signInPath: input.signInPath ?? null,
|
|
152
232
|
trustProxy,
|
|
153
|
-
trustedProxyHops
|
|
154
|
-
bodyLimitBytes:
|
|
233
|
+
trustedProxyHops,
|
|
234
|
+
bodyLimitBytes: assertFiniteCount(
|
|
235
|
+
'bodyLimitBytes',
|
|
236
|
+
input.bodyLimitBytes ?? 1_048_576,
|
|
237
|
+
Number.MAX_SAFE_INTEGER,
|
|
238
|
+
'a whole number of bytes',
|
|
239
|
+
'bodyLimitBytes: 1_048_576',
|
|
240
|
+
),
|
|
155
241
|
// 30s: longer than any request a browser waits out, shorter than the 15s drain budget times
|
|
156
242
|
// two, so a rolling restart cannot be held open by work started just before SIGTERM.
|
|
157
|
-
requestTimeoutMs:
|
|
158
|
-
|
|
159
|
-
|
|
243
|
+
requestTimeoutMs: assertFiniteCount(
|
|
244
|
+
'requestTimeoutMs',
|
|
245
|
+
input.requestTimeoutMs ?? 30_000,
|
|
246
|
+
Number.MAX_SAFE_INTEGER,
|
|
247
|
+
'a whole number of milliseconds, where 0 means no deadline',
|
|
248
|
+
'requestTimeoutMs: 30_000',
|
|
249
|
+
),
|
|
250
|
+
maxInflight: assertFiniteCount(
|
|
251
|
+
'maxInflight',
|
|
252
|
+
input.maxInflight ?? 1_000,
|
|
253
|
+
Number.MAX_SAFE_INTEGER,
|
|
254
|
+
'a whole number of requests, where 0 means never shed',
|
|
255
|
+
'maxInflight: 1_000',
|
|
256
|
+
),
|
|
257
|
+
drainTimeoutMs:
|
|
258
|
+
input.drainTimeoutMs === undefined || input.drainTimeoutMs === null
|
|
259
|
+
? null
|
|
260
|
+
: assertFiniteCount(
|
|
261
|
+
'drainTimeoutMs',
|
|
262
|
+
input.drainTimeoutMs,
|
|
263
|
+
Number.MAX_SAFE_INTEGER,
|
|
264
|
+
'a whole number of milliseconds, or null for the lifecycle default',
|
|
265
|
+
'drainTimeoutMs: 15_000',
|
|
266
|
+
),
|
|
160
267
|
locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
|
|
161
268
|
tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
|
|
162
269
|
cors,
|
package/src/error-map.ts
CHANGED
|
@@ -38,6 +38,10 @@ export const ERROR_STATUS = {
|
|
|
38
38
|
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
39
39
|
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
40
40
|
X_CORS_CONFIG_INVALID: 500,
|
|
41
|
+
// Core's code, borrowed by `defineHttpConfig` for a numeric knob that is not a count. Same
|
|
42
|
+
// construction-time shelf as the row above, and it needs a row for the same reason: this table
|
|
43
|
+
// is closed over every code the package can throw, borrowed ones included.
|
|
44
|
+
X_CONFIG_INVALID: 500,
|
|
41
45
|
X_CSP_DIRECTIVE_INVALID: 500,
|
|
42
46
|
// Thrown while the server is being constructed, so no request is ever answered with it either.
|
|
43
47
|
// The row exists because this table is the closed one: a code missing from it is a 500 anyway,
|
|
@@ -271,6 +275,13 @@ export const ERROR_STATUS = {
|
|
|
271
275
|
// negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
|
|
272
276
|
// query or body the caller wrote — the same place `X_IMAGE_QUERY_INVALID` comes from.
|
|
273
277
|
X_LOCALE_UNSUPPORTED: 400,
|
|
278
|
+
// @ultimat3/core (declared beside `assertLocale`; `@ultimat3/time` owned it until 16.x) — the
|
|
279
|
+
// sibling of the row above, and strictly more the caller's fault: not a tag at all. It was
|
|
280
|
+
// pinned as unable to reach a request because the `locale` stage negotiates and never throws,
|
|
281
|
+
// which is true of that stage and irrelevant to `?locale=`, a path segment or an action input
|
|
282
|
+
// reaching `formatDate` / `formatMoney` / `describeCron`. Those raised it and answered 500,
|
|
283
|
+
// paging the on-call for a string the caller typed.
|
|
284
|
+
X_LOCALE_INVALID: 400,
|
|
274
285
|
// @ultimat3/money — a well-formed code this process carries no row for. The currency table is
|
|
275
286
|
// OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any
|
|
276
287
|
// `^[A-Z]{3}$`: `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the OpenAPI `pattern` emitted from
|
package/src/errors.ts
CHANGED
|
@@ -56,6 +56,12 @@ export const HTTP_BORROWED_ERROR_CODES = [
|
|
|
56
56
|
// the lifecycle that answers `isDraining()` is core's. The `admit` stage is its first thrower:
|
|
57
57
|
// this package documented answering 503 while draining and had no reader of the flag at all.
|
|
58
58
|
'X_DRAINING',
|
|
59
|
+
// Core's, and the code `app.config.ts is invalid` already means — borrowed for the numeric knobs
|
|
60
|
+
// `defineHttpConfig` screens, the same way `@ultimat3/auth` borrows it for a `defineAuth`
|
|
61
|
+
// declaration it cannot honour. A per-knob code of our own would be a second answer to a
|
|
62
|
+
// question core has already answered, and the throw happens while config resolves, so no request
|
|
63
|
+
// is ever answered with it.
|
|
64
|
+
'X_CONFIG_INVALID',
|
|
59
65
|
] as const;
|
|
60
66
|
|
|
61
67
|
/** Every code http can throw: the ones it owns plus the two it borrows. */
|
|
@@ -334,6 +340,52 @@ export const trustProxyUnset = (): HttpError =>
|
|
|
334
340
|
fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
|
|
335
341
|
});
|
|
336
342
|
|
|
343
|
+
/**
|
|
344
|
+
* A numeric knob that is not a count, refused where `app.config.ts` still names it.
|
|
345
|
+
*
|
|
346
|
+
* Every one of these arrives as `Number(process.env.X)` as often as a literal, and `NaN` is not
|
|
347
|
+
* nullish — so `??` passes it through, `Math.max`/`Math.floor` propagate it, and every comparison
|
|
348
|
+
* downstream then answers FALSE. What that produces is never a wrong number, it is the guard
|
|
349
|
+
* switching itself off: `bodyLimitBytes` stops capping the body, `maxInflight` stops shedding,
|
|
350
|
+
* `trustedProxyHops` stops trusting the proxy it was set for, and `requestTimeoutMs` arms a
|
|
351
|
+
* `setTimeout(fn, NaN)` — which is 1ms — so every request 504s. `Math.max(1, x)` is not a
|
|
352
|
+
* validator, which is exactly what `trustedProxyHops` was using.
|
|
353
|
+
*/
|
|
354
|
+
export const httpCountInvalid = (
|
|
355
|
+
name: string,
|
|
356
|
+
value: number,
|
|
357
|
+
expected: string,
|
|
358
|
+
example: string,
|
|
359
|
+
): HttpError =>
|
|
360
|
+
new HttpError({
|
|
361
|
+
code: 'X_CONFIG_INVALID',
|
|
362
|
+
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`,
|
|
363
|
+
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`,
|
|
364
|
+
meta: { option: name, value: String(value) },
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* A route DECLARING a cache age that a cache cannot read. `X_CONFIG_INVALID` and not a code of its
|
|
369
|
+
* own, for the reason the borrowed list gives: this is a declaration the framework cannot honour,
|
|
370
|
+
* and it is thrown while the route table is built, so no request is ever answered with it.
|
|
371
|
+
*
|
|
372
|
+
* Thrown here because `cacheControl` cannot throw: the response path is total by design — it drops
|
|
373
|
+
* the field and logs — so without this the mistake is a log line once per request, forever, while
|
|
374
|
+
* the response falls back to HEURISTIC caching, the one behaviour no `CacheHint` ever asked for.
|
|
375
|
+
*/
|
|
376
|
+
export const routeCacheInvalid = (
|
|
377
|
+
route: string,
|
|
378
|
+
path: string,
|
|
379
|
+
field: string,
|
|
380
|
+
value: number,
|
|
381
|
+
): HttpError =>
|
|
382
|
+
new HttpError({
|
|
383
|
+
code: 'X_CONFIG_INVALID',
|
|
384
|
+
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`,
|
|
385
|
+
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`,
|
|
386
|
+
meta: { route, path, option: `cache.${field}`, value: String(value) },
|
|
387
|
+
});
|
|
388
|
+
|
|
337
389
|
/**
|
|
338
390
|
* SIGTERM has run the `accept` phase: `readyz` is already 503 and the socket is closing, but a
|
|
339
391
|
* connection the load balancer had not yet stopped using still arrives. Answering it with a
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
|
|
|
16
16
|
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
|
|
17
17
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
18
18
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
19
|
-
export { defineHttpConfig, stripBasePath } from './config';
|
|
19
|
+
export { defineHttpConfig, MAX_PROXY_HOPS, stripBasePath } from './config';
|
|
20
20
|
export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
21
21
|
export {
|
|
22
22
|
actorView,
|
|
@@ -82,6 +82,7 @@ export {
|
|
|
82
82
|
pathInvalid,
|
|
83
83
|
pipelineNoResponse,
|
|
84
84
|
requestTimedOut,
|
|
85
|
+
routeCacheInvalid,
|
|
85
86
|
routeConflict,
|
|
86
87
|
routeNotFound,
|
|
87
88
|
serverNotStarted,
|
package/src/rate-limit.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
|
|
4
4
|
// declaration; the bucket maths lives here so every driver agrees on the numbers.
|
|
5
5
|
import { type Clock, systemClock } from '@ultimat3/core';
|
|
6
|
+
import { httpCountInvalid } from './errors';
|
|
6
7
|
import {
|
|
7
8
|
rateLimited,
|
|
8
9
|
rateLimitInvalid,
|
|
@@ -232,6 +233,17 @@ export interface MemoryRateLimitStore extends RateLimitStore {
|
|
|
232
233
|
readonly size: number;
|
|
233
234
|
}
|
|
234
235
|
|
|
236
|
+
/** A whole positive count, or the config refusal that names it. */
|
|
237
|
+
const assertFiniteKeyCap = (option: string, value: number): number => {
|
|
238
|
+
if (Number.isSafeInteger(value) && value >= 1) return value;
|
|
239
|
+
throw httpCountInvalid(
|
|
240
|
+
`rate limit store ${option}`,
|
|
241
|
+
value,
|
|
242
|
+
'a whole number of at least 1',
|
|
243
|
+
`maxKeys: ${DEFAULT_MAX_RATE_LIMIT_KEYS}`,
|
|
244
|
+
);
|
|
245
|
+
};
|
|
246
|
+
|
|
235
247
|
/**
|
|
236
248
|
* Default driver: correct for one process, which is exactly dev and tests.
|
|
237
249
|
*
|
|
@@ -246,7 +258,12 @@ export interface MemoryRateLimitStore extends RateLimitStore {
|
|
|
246
258
|
export const memoryRateLimitStore = (
|
|
247
259
|
options: { readonly maxKeys?: number | undefined } = {},
|
|
248
260
|
): MemoryRateLimitStore => {
|
|
249
|
-
|
|
261
|
+
// Screened, not clamped. `Math.max(1, Math.floor(x))` was the guard, and `Math.floor(NaN)` is
|
|
262
|
+
// `NaN`: `buckets.size > maxKeys` in `take` is then false so the sweep never runs, and
|
|
263
|
+
// `buckets.size <= maxKeys` in the sweep is false so it would evict nothing if it did. The cap
|
|
264
|
+
// is the only thing between a rotating-address scan and this process's memory, and the keys are
|
|
265
|
+
// addresses the caller chooses — a clamp that quietly answers `NaN` removes it.
|
|
266
|
+
const maxKeys = assertFiniteKeyCap('maxKeys', options.maxKeys ?? DEFAULT_MAX_RATE_LIMIT_KEYS);
|
|
250
267
|
const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
|
|
251
268
|
const buckets = new Map<string, BucketState>();
|
|
252
269
|
let lastSweepMs = Number.NEGATIVE_INFINITY;
|
package/src/response.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Response constructors. Every response in the framework is built here so that
|
|
2
2
|
// content types, charsets and cache semantics are decided once instead of per route.
|
|
3
|
+
import { logger } from '@ultimat3/core';
|
|
3
4
|
import { TIMEZONE_HEADER } from '@ultimat3/time';
|
|
4
5
|
import { toProblem } from './error-facts';
|
|
5
6
|
|
|
@@ -116,17 +117,65 @@ export interface CacheHint {
|
|
|
116
117
|
|
|
117
118
|
export const NO_STORE: CacheHint = { mode: 'no-store' };
|
|
118
119
|
|
|
120
|
+
/** A year, the only age at which `immutable` says anything a shorter one does not. */
|
|
121
|
+
const IMMUTABLE_MAX_AGE_SECONDS = 31_536_000;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* RFC-9111 delta-seconds is `1*DIGIT`, so `max-age=NaN` is not a shorter age or a longer one — it
|
|
125
|
+
* is an unparseable directive, and a conforming cache IGNORES a directive it cannot parse. The
|
|
126
|
+
* response then falls back to HEURISTIC caching (a fraction of `Last-Modified`'s age), which is
|
|
127
|
+
* the one behaviour no `CacheHint` ever asked for and the one nothing downstream can detect.
|
|
128
|
+
* `??` guards nullish and `NaN` is not nullish, so an age computed from a timestamp difference or
|
|
129
|
+
* read out of an env value arrives here intact; a fraction (`ms / 1000`) and a negative (a clock
|
|
130
|
+
* that went backwards) are the same unparseable token.
|
|
131
|
+
*
|
|
132
|
+
* The name carries `finite` deliberately: `bun run finite-bounds` recognises a repair by the shape
|
|
133
|
+
* of the CALL, so this screen spelled `deltaSeconds` read as no screen at all.
|
|
134
|
+
*
|
|
135
|
+
* `isDeltaSeconds` is EXPORTED and has exactly two readers, which is the point of it: this one
|
|
136
|
+
* decides what may be WRITTEN, and `route-cache.ts` decides what may be DECLARED. A byte-identical
|
|
137
|
+
* predicate in both files is how the two drift, and a value one accepts and the other drops is a
|
|
138
|
+
* silent hole in the middle — a route that registers cleanly and then emits no age.
|
|
139
|
+
*
|
|
140
|
+
* TOTAL, never a throw: this is the response path, where refusing turns a bad cache hint into a
|
|
141
|
+
* 500. A dropped age is always the SAFER direction — no `s-maxage` means a shared cache falls back
|
|
142
|
+
* to `max-age`, and a `max-age` of 0 means revalidate — so nothing here can lengthen an age the
|
|
143
|
+
* caller did not ask for. The warning is what keeps it from being silent; the fix belongs at the
|
|
144
|
+
* declaration, and `field` names which one.
|
|
145
|
+
*/
|
|
146
|
+
export const isDeltaSeconds = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
|
|
147
|
+
|
|
148
|
+
const finiteDeltaSeconds = (field: string, value: number): number | undefined => {
|
|
149
|
+
if (isDeltaSeconds(value)) return value;
|
|
150
|
+
logger.warn('http.cache_hint_not_delta_seconds', { field, value: String(value) });
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
153
|
+
|
|
119
154
|
export const cacheControl = (hint: CacheHint): string => {
|
|
120
155
|
if (hint.mode === 'no-store') return 'no-store';
|
|
121
156
|
if (hint.mode === 'immutable') {
|
|
122
|
-
|
|
157
|
+
const age = finiteDeltaSeconds(
|
|
158
|
+
'maxAgeSeconds',
|
|
159
|
+
hint.maxAgeSeconds ?? IMMUTABLE_MAX_AGE_SECONDS,
|
|
160
|
+
);
|
|
161
|
+
// 0 rather than the year: a declared age that is not a number is not evidence for the longest
|
|
162
|
+
// age in the file, and an over-long `immutable` is the one cache mistake a purge cannot undo.
|
|
163
|
+
return `public, max-age=${String(age ?? 0)}, immutable`;
|
|
123
164
|
}
|
|
124
|
-
const parts = [
|
|
165
|
+
const parts = [
|
|
166
|
+
hint.mode,
|
|
167
|
+
`max-age=${String(finiteDeltaSeconds('maxAgeSeconds', hint.maxAgeSeconds ?? 0) ?? 0)}`,
|
|
168
|
+
];
|
|
125
169
|
if (hint.mode === 'public' && hint.sMaxAgeSeconds !== undefined) {
|
|
126
|
-
|
|
170
|
+
const shared = finiteDeltaSeconds('sMaxAgeSeconds', hint.sMaxAgeSeconds);
|
|
171
|
+
if (shared !== undefined) parts.push(`s-maxage=${String(shared)}`);
|
|
127
172
|
}
|
|
128
173
|
if (hint.staleWhileRevalidateSeconds !== undefined) {
|
|
129
|
-
|
|
174
|
+
const stale = finiteDeltaSeconds(
|
|
175
|
+
'staleWhileRevalidateSeconds',
|
|
176
|
+
hint.staleWhileRevalidateSeconds,
|
|
177
|
+
);
|
|
178
|
+
if (stale !== undefined) parts.push(`stale-while-revalidate=${String(stale)}`);
|
|
130
179
|
}
|
|
131
180
|
return parts.join(', ');
|
|
132
181
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Single responsibility: the screen a route's `cache` hint gets where it is DECLARED.
|
|
2
|
+
//
|
|
3
|
+
// The layered form the rest of the framework uses: refuse where a value is WRITTEN, be total where
|
|
4
|
+
// it is USED. `cacheControl` is the total half — it drops a field it cannot emit and warns — and
|
|
5
|
+
// this is the half that throws, because at registration the author is standing right there.
|
|
6
|
+
|
|
7
|
+
import { routeCacheInvalid } from './errors';
|
|
8
|
+
import type { CacheHint } from './response';
|
|
9
|
+
import { isDeltaSeconds } from './response';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The three `CacheHint` fields that reach a `cache-control` directive as a NUMBER. `tags` and
|
|
13
|
+
* `vary` are string lists and `mode` is a closed union the compiler already decides.
|
|
14
|
+
*
|
|
15
|
+
* A tuple of literals rather than `Object.keys(hint)`: the set is the emitter's, not the caller's,
|
|
16
|
+
* so a field added to `CacheHint` and forgotten here is a compile error at the emitter's next edit
|
|
17
|
+
* rather than a silently unscreened age.
|
|
18
|
+
*/
|
|
19
|
+
const DELTA_SECONDS_FIELDS = [
|
|
20
|
+
'maxAgeSeconds',
|
|
21
|
+
'sMaxAgeSeconds',
|
|
22
|
+
'staleWhileRevalidateSeconds',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `isDeltaSeconds` is IMPORTED from `response.ts`, never restated: that file owns what may be
|
|
27
|
+
* emitted, this one what may be declared, and a byte-identical predicate in both is how a screen
|
|
28
|
+
* and its emitter come to accept different sets — a route that registers cleanly and then emits no
|
|
29
|
+
* age at all. **Zero stays legal at both ends**: `max-age=0` means "revalidate every time" and the
|
|
30
|
+
* framework's own defaults declare it — `PRIVATE_CACHE`, `defaultCache`'s anonymous hint and the
|
|
31
|
+
* CLI's authorized-object hint all carry `maxAgeSeconds: 0`, so a floor of 1 would refuse the
|
|
32
|
+
* framework at its own boot.
|
|
33
|
+
*
|
|
34
|
+
* Called once per route by `createRouter`, which is the one way a `Route` becomes matchable — so
|
|
35
|
+
* every hint an app can serve has been through here, including the ones `@ultimat3/cli` mints for
|
|
36
|
+
* favicons, dev assets and storage objects.
|
|
37
|
+
*/
|
|
38
|
+
export const assertRouteCache = (
|
|
39
|
+
hint: CacheHint | undefined,
|
|
40
|
+
route: string,
|
|
41
|
+
path: string,
|
|
42
|
+
): void => {
|
|
43
|
+
if (hint === undefined) return;
|
|
44
|
+
for (const field of DELTA_SECONDS_FIELDS) {
|
|
45
|
+
const value = hint[field];
|
|
46
|
+
if (value === undefined || isDeltaSeconds(value)) continue;
|
|
47
|
+
throw routeCacheInvalid(route, path, field, value);
|
|
48
|
+
}
|
|
49
|
+
};
|
package/src/router.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { routeConflict } from './errors';
|
|
|
16
16
|
import type { Bucket } from './rate-limit';
|
|
17
17
|
import type { UltimateRequest } from './request';
|
|
18
18
|
import type { CacheHint } from './response';
|
|
19
|
+
import { assertRouteCache } from './route-cache';
|
|
19
20
|
import type { Schema } from './validate';
|
|
20
21
|
|
|
21
22
|
export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
|
@@ -129,6 +130,10 @@ const segmentsOf = (path: string): readonly string[] =>
|
|
|
129
130
|
export const createRouter = (routes: readonly Route[]): RouteTable => {
|
|
130
131
|
const root = node();
|
|
131
132
|
for (const route of routes) {
|
|
133
|
+
// Before the trie is touched: a hint that cannot be emitted is refused where it was WRITTEN,
|
|
134
|
+
// not on the response path — `cacheControl` is total there by design, so the alternative is a
|
|
135
|
+
// log line once per request and a response that quietly falls back to heuristic caching.
|
|
136
|
+
assertRouteCache(route.meta.cache, route.meta.name, route.path);
|
|
132
137
|
let current = root;
|
|
133
138
|
const segments = segmentsOf(route.path);
|
|
134
139
|
for (const [index, segment] of segments.entries()) {
|
package/src/webhook-verify.ts
CHANGED
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
WEBHOOK_TOPIC_HEADER,
|
|
23
23
|
webhookMac,
|
|
24
24
|
} from '@ultimat3/core';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
bodyInvalid,
|
|
27
|
+
httpCountInvalid,
|
|
28
|
+
webhookSignatureInvalid,
|
|
29
|
+
webhookSignatureStale,
|
|
30
|
+
} from './errors';
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* How far a delivery's timestamp may sit from this clock, either way. Five minutes is the window
|
|
@@ -32,6 +37,23 @@ import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './e
|
|
|
32
37
|
*/
|
|
33
38
|
export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000;
|
|
34
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The screen above `toleranceMs`, kept beside the constant it defaults to.
|
|
42
|
+
*
|
|
43
|
+
* The `Finite` in the name is load-bearing: `bun run finite-bounds` recognises a repair by the
|
|
44
|
+
* shape of the CALL, so a screen named for what it guards is invisible to the ratchet that would
|
|
45
|
+
* otherwise catch the next one of these.
|
|
46
|
+
*/
|
|
47
|
+
const assertFiniteToleranceMs = (toleranceMs: number): number => {
|
|
48
|
+
if (Number.isSafeInteger(toleranceMs) && toleranceMs >= 0) return toleranceMs;
|
|
49
|
+
throw httpCountInvalid(
|
|
50
|
+
'webhook toleranceMs',
|
|
51
|
+
toleranceMs,
|
|
52
|
+
'a whole number of milliseconds, zero or more',
|
|
53
|
+
'toleranceMs: 300_000',
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
35
57
|
/**
|
|
36
58
|
* Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this
|
|
37
59
|
* function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a
|
|
@@ -39,6 +61,23 @@ export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000;
|
|
|
39
61
|
*/
|
|
40
62
|
export const DEFAULT_WEBHOOK_BODY_LIMIT = 1_048_576;
|
|
41
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The same screen above `maxBytes`, refused HERE and not only where it lands.
|
|
66
|
+
*
|
|
67
|
+
* `readWithinLimit` refuses a non-finite limit as well, and that is one file away: its `fix:` names
|
|
68
|
+
* core's reader, while the edit the caller has to make is the `maxBytes` written on this call. Zero
|
|
69
|
+
* is excluded on purpose — a cap of nothing refuses every delivery a sender can make.
|
|
70
|
+
*/
|
|
71
|
+
const assertFiniteBodyLimit = (maxBytes: number): number => {
|
|
72
|
+
if (Number.isSafeInteger(maxBytes) && maxBytes >= 1) return maxBytes;
|
|
73
|
+
throw httpCountInvalid(
|
|
74
|
+
'webhook maxBytes',
|
|
75
|
+
maxBytes,
|
|
76
|
+
'a whole number of bytes, at least 1',
|
|
77
|
+
`maxBytes: ${DEFAULT_WEBHOOK_BODY_LIMIT}`,
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
42
81
|
export interface WebhookVerifyOptions {
|
|
43
82
|
/** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
|
|
44
83
|
readonly secret: string;
|
|
@@ -97,7 +136,7 @@ export async function verifyWebhookSignature(
|
|
|
97
136
|
);
|
|
98
137
|
}
|
|
99
138
|
|
|
100
|
-
const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT;
|
|
139
|
+
const maxBytes = assertFiniteBodyLimit(options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT);
|
|
101
140
|
// Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that
|
|
102
141
|
// announces no length must not be able to make this handler hold an unbounded payload before the
|
|
103
142
|
// signature it was never going to pass is even computed.
|
|
@@ -123,7 +162,11 @@ export async function verifyWebhookSignature(
|
|
|
123
162
|
}
|
|
124
163
|
|
|
125
164
|
const signedAtMs = signature.timestampSeconds * 1_000;
|
|
126
|
-
|
|
165
|
+
// The tolerance IS the replay window, so it is screened before it is compared against: `skewMs >
|
|
166
|
+
// NaN` is false, which does not widen the window — it removes it, and a webhook captured a year
|
|
167
|
+
// ago verifies forever with every other check passing. Refused as the config it is, and not by
|
|
168
|
+
// the sender's error: nothing the caller sends can fix it.
|
|
169
|
+
const toleranceMs = assertFiniteToleranceMs(options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS);
|
|
127
170
|
const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs);
|
|
128
171
|
// Both directions: a sender whose clock runs ahead is the same replay window pointed the other
|
|
129
172
|
// way, and accepting the future half doubles it.
|