@ultimat3/http 11.3.0 → 12.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 +57 -2
- package/README.md +51 -5
- package/package.json +5 -5
- package/src/app-config.ts +146 -0
- package/src/config.ts +5 -2
- package/src/context.ts +8 -0
- package/src/cors.ts +2 -2
- package/src/deadline.ts +18 -1
- package/src/error-facts.ts +193 -0
- package/src/error-map.ts +42 -193
- package/src/errors.ts +8 -6
- package/src/index.ts +14 -7
- package/src/overlay.ts +1 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit-errors.ts +23 -7
- package/src/rate-limit.ts +61 -5
- package/src/response.ts +1 -1
- package/src/stages.ts +36 -14
- package/src/type-pins.ts +24 -0
package/CLAUDE.md
CHANGED
|
@@ -20,6 +20,27 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
20
20
|
## Rules
|
|
21
21
|
|
|
22
22
|
- Route `meta.auth` is required. Never default a route to public.
|
|
23
|
+
- **An app declares its half of `HttpConfig` through `configureHttp()`, and the boot lays its own
|
|
24
|
+
facts over it** (`As of 2026-08-24`). Until 12.0.0 the entire tuning surface was **unreachable
|
|
25
|
+
from a shipped app**: `AppConfig` has never had an `http` key, `RuntimeOverrides` carries none,
|
|
26
|
+
and the only construction any shipped process made was one fixed literal in
|
|
27
|
+
`packages/cli/src/dev-roles.ts` passing eight boot facts — so `DEFAULT_CORS.origins` was `[]` in
|
|
28
|
+
every deployment (an SPA on `app.example.com` calling `api.example.com` could not work, ever),
|
|
29
|
+
`bodyLimitBytes` was 1 MiB for a 4 MB CSV endpoint, `requestTimeoutMs` 30s for a five-minute
|
|
30
|
+
export, and `rateLimit.buckets` was 120 burst / 2 rps for a bank and a blog alike. Fourteen
|
|
31
|
+
`fix:` lines told the reader to edit `http.<key>` in `app.config.ts`, which has never held one.
|
|
32
|
+
It is a registration and not a config key for `configureAuthenticator`'s reason, stated in
|
|
33
|
+
`hooks.ts`: `@ultimat3/core` is tier 0 and cannot hold this package's types, so an `http` block
|
|
34
|
+
on `AppConfig` would be a **second declaration** of `HttpConfigInput` in a package that can
|
|
35
|
+
never check it against this one. `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`,
|
|
36
|
+
**derived, never listed**: a key the boot always overwrites (`port`, `hostname`, `dev`,
|
|
37
|
+
`buildId`, `signInPath`, `trustProxy`, `trustedProxyHops`, `rateLimit.scope`) is a type error
|
|
38
|
+
where an app writes it, rather than a value silently discarded at every boot. `mergeHttpConfig`
|
|
39
|
+
merges one level down — `security.csp.extend` per DIRECTIVE, because the app's CDN source and
|
|
40
|
+
the boot's inline-script hash are each the whole answer for something, and either alone breaks a
|
|
41
|
+
page. `type-pins.ts` holds the other half: a key on `HttpConfig` and not on `HttpConfigInput` is
|
|
42
|
+
a build error, which `scripts/config-readers.ts` cannot see — that ratchet walks `AppConfig` and
|
|
43
|
+
asks whether a key is READ, and this is the mirror question.
|
|
23
44
|
- **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and
|
|
24
45
|
`asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set
|
|
25
46
|
none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw
|
|
@@ -60,6 +81,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
60
81
|
with `AbortSignal.any`, which is what `context.ts` had documented and nothing wired — a closed
|
|
61
82
|
tab held its handler, its pool slot and its vendor connection for the whole 30s. `expired` stays
|
|
62
83
|
the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
|
|
84
|
+
**And it leaves this process on the next hop's headers, `As of 2026-08-24`**:
|
|
85
|
+
`Deadline.deadlineAt` is published as core's `ctx.deadlineAt`, and `traceHeaders()` (tier 0, the
|
|
86
|
+
one thing both typed clients spread before the caller's own headers) sends what is LEFT as
|
|
87
|
+
`x-request-timeout-ms`. Before that the header had exactly one reader — `resolveTimeoutMs`, in
|
|
88
|
+
this file — and **zero writers anywhere in the tree**, so gateway → A (30s) → B meant a call made
|
|
89
|
+
at t=29 started B on a FRESH 30s: real work, holding a pool slot and a vendor connection, half a
|
|
90
|
+
minute after A's socket was answered `X_TIMEOUT`. A spent budget sends no header at all rather
|
|
91
|
+
than `0`, because `resolveTimeoutMs` ignores anything under 1ms and falls back to its own.
|
|
63
92
|
With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared
|
|
64
93
|
never-aborted singleton, which every such request used to share — one `abort` listener per
|
|
65
94
|
request, accumulating for the life of the process. Always `deadline.clear()` in the `finally` —
|
|
@@ -242,6 +271,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
242
271
|
`lastResort` spells its one `type` as a literal because that function calls nothing, and
|
|
243
272
|
`pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
|
|
244
273
|
cannot drift. Never assert either value as a copied string — import the constant.
|
|
274
|
+
- **`error-map.ts` answers the status; `error-facts.ts` renders the throwable** (`As of
|
|
275
|
+
2026-08-24`). One file did both and reached 501 lines, over the ceiling. The seam between
|
|
276
|
+
them is `declaredStatusFor(code)` — `number | undefined`, exported to this package only —
|
|
277
|
+
because the two questions are genuinely different: `statusFor` always answers a number, while
|
|
278
|
+
"did ANYBODY classify this code" is what decides whether a 5xx may carry the throwable's own
|
|
279
|
+
words back to the caller (`isUnclassifiedFailure`). Imports go one way, `error-facts.ts` →
|
|
280
|
+
`error-map.ts`; a status read from the facts file would be the second table this package
|
|
281
|
+
spent a release deleting.
|
|
245
282
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
246
283
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
247
284
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
@@ -309,8 +346,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
309
346
|
`X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
|
|
310
347
|
the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
|
|
311
348
|
not one.
|
|
349
|
+
- **One request spends a LIST of rate-limit keys, and the tenant's is the second** (`As of
|
|
350
|
+
2026-08-24`). `rateLimitKey` picked ONE subject — actor > org > ip, exclusive — and `actorView`
|
|
351
|
+
answers `null` for anonymous, so `orgId` was consulted only for a caller with an org and no id:
|
|
352
|
+
**no authenticated request ever touched an org bucket**. A tenant with 8,000 seats whose
|
|
353
|
+
integration entered a retry loop therefore took 8,000 × the per-actor burst against one shared
|
|
354
|
+
pool, every bucket inside its own limit, and no number an operator could set would have refused
|
|
355
|
+
it — while `@ultimat3/jobs` has had `perTenant` since it shipped. `rateLimitSpends` answers the
|
|
356
|
+
caller's key **and** `tenant|org:<id>` when the app declared `rateLimit.tenantBucket`; the stage
|
|
357
|
+
spends them in order and stops at the first refusal, so a caller its own bucket already refused
|
|
358
|
+
costs its tenant nothing. The tenant key is deliberately NOT scoped to the route — a per-route
|
|
359
|
+
tenant bucket is the same number multiplied by the route table, which is not a cap. `null` is
|
|
360
|
+
the default because one tenant is a person and the next is five thousand seats (axiom 8), and a
|
|
361
|
+
name nothing declares is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, never a
|
|
362
|
+
silent fall-through to `default`. The headers report the bucket **closest to refusing**: telling
|
|
363
|
+
a client `remaining: 99` off its own bucket while its tenant's holds 2 is a number that plans a
|
|
364
|
+
caller into a 429.
|
|
312
365
|
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
313
|
-
The key falls back to the connection address (`
|
|
366
|
+
The key falls back to the connection address (`rateLimitSpends`), so a scan rotating through an
|
|
314
367
|
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
315
368
|
entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
|
|
316
369
|
missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
|
|
@@ -402,7 +455,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
402
455
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
403
456
|
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
404
457
|
| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
|
|
405
|
-
| `error-map.ts` | code → status table
|
|
458
|
+
| `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
|
|
459
|
+
| `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document, the three terminal lines |
|
|
406
460
|
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
|
|
407
461
|
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
408
462
|
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
@@ -421,6 +475,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
421
475
|
| `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
|
|
422
476
|
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
|
|
423
477
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
478
|
+
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
424
479
|
|
|
425
480
|
## Commands
|
|
426
481
|
|
package/README.md
CHANGED
|
@@ -13,8 +13,10 @@ to skip.
|
|
|
13
13
|
| the ordered request lifecycle | `pipeline.ts` |
|
|
14
14
|
| typed request (params, query, body) | `request.ts` |
|
|
15
15
|
| response constructors + `problem()` | `response.ts` |
|
|
16
|
-
| code → status,
|
|
16
|
+
| code → status, closed table | `error-map.ts` |
|
|
17
|
+
| `factsOf()`, the problem document, the terminal lines | `error-facts.ts` |
|
|
17
18
|
| token-bucket limiting, `toBucket` | `rate-limit.ts` |
|
|
19
|
+
| the app's own HTTP declaration, and the boot's facts over it | `app-config.ts` |
|
|
18
20
|
| CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
|
|
19
21
|
| CSRF (origin proof for a credentialed write) | `csrf.ts` |
|
|
20
22
|
| the request deadline and `ctx.signal` | `deadline.ts` |
|
|
@@ -22,6 +24,37 @@ to skip.
|
|
|
22
24
|
| the inbound request id and trace, read before the span | `correlation.ts` |
|
|
23
25
|
| dev error overlay | `overlay.ts` |
|
|
24
26
|
|
|
27
|
+
## What an app declares: `configureHttp()`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// apps/web/app/http.ts — module scope, imported by the app like any other module
|
|
31
|
+
import { configureHttp } from '@ultimat3/http';
|
|
32
|
+
|
|
33
|
+
configureHttp({
|
|
34
|
+
cors: { origins: ['https://app.example.com'], credentials: true },
|
|
35
|
+
bodyLimitBytes: 8 * 1024 * 1024, // this API takes a 4 MB CSV
|
|
36
|
+
requestTimeoutMs: 300_000, // and an export that really does take five minutes
|
|
37
|
+
rateLimit: {
|
|
38
|
+
tenantBucket: 'tenant',
|
|
39
|
+
buckets: { tenant: { capacity: 5_000, refillPerSecond: 100 } },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
One registration, read once by whatever process starts the web role — the same seam
|
|
45
|
+
`configureAuthenticator()` is. **Breaking, `As of 2026-08-24`**: before it, the only `HttpConfig`
|
|
46
|
+
any shipped process built was a fixed literal inside `@ultimat3/cli`, so none of the four values
|
|
47
|
+
above could be set from an app at all — `cors.origins` was `[]` in every deployment, which refuses
|
|
48
|
+
every cross-origin browser call, permanently. `AppConfig` has never carried an `http` key and does
|
|
49
|
+
not gain one: `@ultimat3/core` is tier 0 and cannot hold this package's types.
|
|
50
|
+
|
|
51
|
+
`AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>` — `port`, `hostname`, `dev`,
|
|
52
|
+
`buildId`, `signInPath`, `trustProxy`, `trustedProxyHops` and `rateLimit.scope` are the boot's, and
|
|
53
|
+
writing one here is a **type error** rather than a value silently overwritten at the next boot.
|
|
54
|
+
`mergeHttpConfig(configuredHttp(), boot)` is the layering, and it merges `security.csp.extend` per
|
|
55
|
+
directive: the app's CDN source and the boot's inline-script hash are each the whole answer for
|
|
56
|
+
something.
|
|
57
|
+
|
|
25
58
|
## The pipeline is the guarantee
|
|
26
59
|
|
|
27
60
|
```
|
|
@@ -57,6 +90,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
57
90
|
| HSTS | emitted only when the connection is affirmatively https (`ctx.https`); never by the zero-argument default |
|
|
58
91
|
| `rateLimit.scope: 'shared'` on a per-process store | `X_RATE_LIMIT_NOT_SHARED` at `createServer`, because N replicas each holding their own counters enforce N × every configured number |
|
|
59
92
|
| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `createServer`, because the loser would be a number someone read and nothing applied |
|
|
93
|
+
| 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 |
|
|
60
94
|
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
|
|
61
95
|
| 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 |
|
|
62
96
|
| `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 |
|
|
@@ -102,6 +136,18 @@ so nothing can be wrong.
|
|
|
102
136
|
`rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
|
|
103
137
|
maths stays in `createRateLimiter`, so every driver agrees on the numbers.
|
|
104
138
|
|
|
139
|
+
**One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
|
|
140
|
+
else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
|
|
141
|
+
builder used to pick exactly ONE subject and consult `orgId` only when there was no actor id,
|
|
142
|
+
which no authenticated request ever satisfies: a tenant with 8,000 seats took 8,000 × the
|
|
143
|
+
per-actor burst against one shared pool, every bucket inside its own limit, and no number an
|
|
144
|
+
operator could set would have refused it. The tenant key is `tenant|org:<id>` and is deliberately
|
|
145
|
+
NOT scoped to the route — a per-route tenant bucket is the same allowance once per route. The
|
|
146
|
+
spend stops at the first refusal, so a caller its own bucket refused costs its tenant nothing, and
|
|
147
|
+
the `ratelimit-*` headers report the bucket closest to refusing. `tenantBucket` defaults to `null`:
|
|
148
|
+
one tenant is a person and the next is five thousand seats, so there is no allowance a framework
|
|
149
|
+
can pick for you.
|
|
150
|
+
|
|
105
151
|
**A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table
|
|
106
152
|
and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,
|
|
107
153
|
`scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
|
|
@@ -217,7 +263,7 @@ in a table row and a table row has no anchor. Assert against `problemTypeFor` an
|
|
|
217
263
|
|
|
218
264
|
## Boundaries
|
|
219
265
|
|
|
220
|
-
Tier 2. Imports `@ultimat3/core` and `@ultimat3/
|
|
221
|
-
policy evaluation arrive through
|
|
222
|
-
`@ultimat3/policy` is a sibling tier. There is no
|
|
223
|
-
handler, the pipeline is everything else.
|
|
266
|
+
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
|
|
267
|
+
tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
|
|
268
|
+
`ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
|
|
269
|
+
plugin API: `Middleware` wraps a handler, the pipeline is everything else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.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": "12.0.0",
|
|
35
|
+
"@ultimat3/i18n": "12.0.0",
|
|
36
|
+
"@ultimat3/schema": "12.0.0",
|
|
37
|
+
"@ultimat3/time": "12.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The app's own HTTP declaration, and the layering that keeps a boot fact above it. One
|
|
2
|
+
// registration site, read once by whatever process starts the web role — the same seam
|
|
3
|
+
// `configureAuthenticator()` is, and for the same reason: `@ultimat3/core` is tier 0 and cannot
|
|
4
|
+
// hold this package's types, so an `http` block on `AppConfig` would be a second declaration of
|
|
5
|
+
// `HttpConfigInput` in a package that can never check it against this one.
|
|
6
|
+
|
|
7
|
+
import type { HttpConfigInput } from './config';
|
|
8
|
+
import type { RateLimitConfig } from './rate-limit';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The keys the BOOT owns, and the reason this type is an `Omit` rather than a hand-written list of
|
|
12
|
+
* what an app may say. Each of these is a fact about the PROCESS — the port it was told to bind,
|
|
13
|
+
* the build it serves, whether it is `x dev`, how many proxies the deployment puts in front of it,
|
|
14
|
+
* `auth.signInPath` from `app.config.ts` — so a value an app wrote for one of them would be
|
|
15
|
+
* overwritten at every boot: a switch with no wire, which is the defect this whole surface exists
|
|
16
|
+
* to remove. Refused at the type level, which is the build error that enforces it.
|
|
17
|
+
*/
|
|
18
|
+
export type BootOwnedHttpKey =
|
|
19
|
+
| 'port'
|
|
20
|
+
| 'hostname'
|
|
21
|
+
| 'dev'
|
|
22
|
+
| 'buildId'
|
|
23
|
+
| 'signInPath'
|
|
24
|
+
| 'trustProxy'
|
|
25
|
+
| 'trustedProxyHops';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What an app declares. `rateLimit.scope` is boot-owned for the same reason as the keys above:
|
|
29
|
+
* `startWeb` DERIVES it from the store it installed, so a literal here would be a second
|
|
30
|
+
* declaration quietly contradicting the object beside it — and `assertRateLimitScope` compares
|
|
31
|
+
* exactly those two halves.
|
|
32
|
+
*/
|
|
33
|
+
export type AppHttpConfig = Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'> & {
|
|
34
|
+
readonly rateLimit?: Omit<Partial<RateLimitConfig>, 'scope'> | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The app's declaration, if it made one. A single value and not a list, exactly as
|
|
39
|
+
* `configuredAuthenticator` is: two answers to "how does this server bind and what does it admit"
|
|
40
|
+
* is two configurations, and the one that ran first wins.
|
|
41
|
+
*
|
|
42
|
+
* Process-global for the reason that one is: the app has exactly one boot, and every host that
|
|
43
|
+
* starts a server (`x dev`, `apps/web/server.ts`) would otherwise need its own way to be handed
|
|
44
|
+
* the same values — which is what left the whole tuning surface unreachable, since the only
|
|
45
|
+
* shipped construction was a fixed literal inside the CLI.
|
|
46
|
+
*/
|
|
47
|
+
let declared: AppHttpConfig | undefined;
|
|
48
|
+
|
|
49
|
+
export const configureHttp = (config: AppHttpConfig): void => {
|
|
50
|
+
declared = config;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** What the boot layers its own facts over. `undefined` means the locked defaults stand. */
|
|
54
|
+
export const configuredHttp = (): AppHttpConfig | undefined => declared;
|
|
55
|
+
|
|
56
|
+
/** Test seam. Production configures once at module scope and never unconfigures. */
|
|
57
|
+
export const resetHttpConfig = (): void => {
|
|
58
|
+
declared = undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type SecurityInput = NonNullable<HttpConfigInput['security']>;
|
|
62
|
+
type CspInput = NonNullable<SecurityInput['csp']>;
|
|
63
|
+
type CspExtend = NonNullable<CspInput['extend']>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Per directive, both lists. The app's `script-src` is a CDN it serves scripts from and the boot's
|
|
67
|
+
* is the sha256 of the hydration runtime this process emits inline — each is the whole answer for
|
|
68
|
+
* something, so a merge that let either win breaks a page: the CDN script, or every island.
|
|
69
|
+
*
|
|
70
|
+
* Built through a `Map`, never by assigning `out[directive]`: a directive named `__proto__` sets
|
|
71
|
+
* the PROTOTYPE rather than a key, which is a source silently dropped from the one header this
|
|
72
|
+
* package locks down hardest.
|
|
73
|
+
*/
|
|
74
|
+
const mergeCspExtend = (
|
|
75
|
+
app: CspExtend | undefined,
|
|
76
|
+
boot: CspExtend | undefined,
|
|
77
|
+
): CspExtend | undefined => {
|
|
78
|
+
if (app === undefined) return boot;
|
|
79
|
+
if (boot === undefined) return app;
|
|
80
|
+
const merged = new Map<string, readonly string[]>(Object.entries(app));
|
|
81
|
+
for (const [directive, sources] of Object.entries(boot)) {
|
|
82
|
+
merged.set(directive, [...(merged.get(directive) ?? []), ...sources]);
|
|
83
|
+
}
|
|
84
|
+
return Object.fromEntries(merged);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const mergeSecurity = (
|
|
88
|
+
app: SecurityInput | undefined,
|
|
89
|
+
boot: SecurityInput | undefined,
|
|
90
|
+
): SecurityInput | undefined => {
|
|
91
|
+
if (app === undefined) return boot;
|
|
92
|
+
if (boot === undefined) return app;
|
|
93
|
+
const extend = mergeCspExtend(app.csp?.extend, boot.csp?.extend);
|
|
94
|
+
const csp: CspInput = {
|
|
95
|
+
...app.csp,
|
|
96
|
+
...boot.csp,
|
|
97
|
+
...(extend === undefined ? {} : { extend }),
|
|
98
|
+
};
|
|
99
|
+
return { ...app, ...boot, csp };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The app's declaration with the boot's own facts laid OVER it — the one order that can be right.
|
|
104
|
+
* `buildId`, the port, the CSP hashes of what this process emits and the scope of the store it
|
|
105
|
+
* installed are all things the boot measured; an app can only have guessed at them. Everything
|
|
106
|
+
* else the app said survives, which is the whole point of it having said anything.
|
|
107
|
+
*
|
|
108
|
+
* Sections merge one level down rather than being replaced whole: `security: { csp: { extend } }`
|
|
109
|
+
* from the boot would otherwise delete an app's `hsts`, `frameAncestors` and its own extends, and
|
|
110
|
+
* `rateLimit: { scope }` would delete every bucket it declared.
|
|
111
|
+
*/
|
|
112
|
+
export const mergeHttpConfig = (
|
|
113
|
+
app: AppHttpConfig | undefined,
|
|
114
|
+
boot: HttpConfigInput,
|
|
115
|
+
): HttpConfigInput => {
|
|
116
|
+
if (app === undefined) return boot;
|
|
117
|
+
// `rateLimit` is lifted out of the spread rather than overwritten by it: `AppHttpConfig` types
|
|
118
|
+
// it WITHOUT `scope` and `HttpConfigInput` types it with, so under `exactOptionalPropertyTypes`
|
|
119
|
+
// the spread of the narrower optional is not assignable to the wider one. The merged value is
|
|
120
|
+
// computed below and put back.
|
|
121
|
+
const { rateLimit: appRateLimit, ...appRest } = app;
|
|
122
|
+
const cors =
|
|
123
|
+
app.cors === undefined && boot.cors === undefined ? undefined : { ...app.cors, ...boot.cors };
|
|
124
|
+
const csrf =
|
|
125
|
+
app.csrf === undefined && boot.csrf === undefined ? undefined : { ...app.csrf, ...boot.csrf };
|
|
126
|
+
const locale =
|
|
127
|
+
app.locale === undefined && boot.locale === undefined
|
|
128
|
+
? undefined
|
|
129
|
+
: { ...app.locale, ...boot.locale };
|
|
130
|
+
const tz = app.tz === undefined && boot.tz === undefined ? undefined : { ...app.tz, ...boot.tz };
|
|
131
|
+
const rateLimit: Partial<RateLimitConfig> | undefined =
|
|
132
|
+
appRateLimit === undefined && boot.rateLimit === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: { ...appRateLimit, ...boot.rateLimit };
|
|
135
|
+
const security = mergeSecurity(app.security, boot.security);
|
|
136
|
+
return {
|
|
137
|
+
...appRest,
|
|
138
|
+
...boot,
|
|
139
|
+
...(cors === undefined ? {} : { cors }),
|
|
140
|
+
...(csrf === undefined ? {} : { csrf }),
|
|
141
|
+
...(locale === undefined ? {} : { locale }),
|
|
142
|
+
...(tz === undefined ? {} : { tz }),
|
|
143
|
+
...(rateLimit === undefined ? {} : { rateLimit }),
|
|
144
|
+
...(security === undefined ? {} : { security }),
|
|
145
|
+
};
|
|
146
|
+
};
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
1
|
+
// The resolver every HTTP config goes through, so a value is either a locked default or an
|
|
2
|
+
// explicit override — never "whatever the first caller passed". It is NOT a slice of
|
|
3
|
+
// `app.config.ts`, which this file claimed for four majors while `AppConfig` has never carried an
|
|
4
|
+
// `http` key: an app declares its half through `configureHttp()` (`app-config.ts`) and the boot
|
|
5
|
+
// lays its own facts over it before calling this.
|
|
3
6
|
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
|
|
4
7
|
import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
|
|
5
8
|
import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
|
package/src/context.ts
CHANGED
|
@@ -80,6 +80,11 @@ export interface RequestContext extends Ctx {
|
|
|
80
80
|
readonly logger: Logger;
|
|
81
81
|
/** Aborted when the caller goes away or the request deadline passes. See `deadline.ts`. */
|
|
82
82
|
readonly signal: AbortSignal;
|
|
83
|
+
/**
|
|
84
|
+
* The instant `signal` will fire at, or `null` with `requestTimeoutMs: 0`. Core's field: a
|
|
85
|
+
* signal can only say "already over", and an outbound hop has to say how much is LEFT.
|
|
86
|
+
*/
|
|
87
|
+
readonly deadlineAt: number | null;
|
|
83
88
|
readonly services: ServiceBag;
|
|
84
89
|
|
|
85
90
|
// Mutable slots, each filled by exactly one pipeline stage. Kept mutable (and
|
|
@@ -135,6 +140,8 @@ export interface RequestContextInit {
|
|
|
135
140
|
readonly logger?: Logger;
|
|
136
141
|
/** The deadline/disconnect signal. Absent means a request nothing can cancel. */
|
|
137
142
|
readonly signal?: AbortSignal;
|
|
143
|
+
/** `Deadline.deadlineAt` — epoch ms. Absent means this request has no budget. */
|
|
144
|
+
readonly deadlineAt?: number | null;
|
|
138
145
|
readonly services?: ServiceBag;
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -174,6 +181,7 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
174
181
|
// context — a callback that outlived the request scope, a logger passed to a driver.
|
|
175
182
|
logger: (init.logger ?? rootLogger).child({ requestId, traceId }),
|
|
176
183
|
signal: init.signal ?? NEVER_ABORTED,
|
|
184
|
+
deadlineAt: init.deadlineAt ?? null,
|
|
177
185
|
// Frozen and explicit. `defineService` factories are NOT installed here: core does not
|
|
178
186
|
// export the installer, so the honest answer for a service nothing passed is
|
|
179
187
|
// `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than
|
package/src/cors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// CORS with a locked default: same-origin only. Cross-origin access is a decision
|
|
2
|
-
//
|
|
1
|
+
// CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
|
|
2
|
+
// once, in `configureHttp({ cors })`, never something a route can quietly opt into.
|
|
3
3
|
|
|
4
4
|
import { corsConfigInvalid } from './errors';
|
|
5
5
|
|
package/src/deadline.ts
CHANGED
|
@@ -4,18 +4,31 @@
|
|
|
4
4
|
// until the process died, and SIGTERM then waited out the whole drain budget for work that would
|
|
5
5
|
// never finish.
|
|
6
6
|
|
|
7
|
+
import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
|
|
7
8
|
import type { HttpConfig } from './config';
|
|
8
9
|
import { requestTimedOut } from './errors';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A caller may SHORTEN this request's deadline, never lengthen it. Honoured without trusting the
|
|
12
13
|
* proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
|
|
14
|
+
*
|
|
15
|
+
* The name is core's, re-exported rather than declared twice: this package READS the header and
|
|
16
|
+
* `@ultimat3/core`'s typed-client wire path WRITES it, and a second literal is a propagation that
|
|
17
|
+
* stops working the day one of the two strings is edited. Same shape as `logger.ts` re-exporting
|
|
18
|
+
* `REDACTED` — one definition, one public path.
|
|
13
19
|
*/
|
|
14
|
-
export
|
|
20
|
+
export { REQUEST_TIMEOUT_HEADER };
|
|
15
21
|
|
|
16
22
|
export interface Deadline {
|
|
17
23
|
/** Aborted when the deadline passes. Handed to the context as `ctx.signal`. */
|
|
18
24
|
readonly signal: AbortSignal;
|
|
25
|
+
/**
|
|
26
|
+
* Epoch ms the budget runs out at, or `null` when there is none — `ctx.deadlineAt`, and what an
|
|
27
|
+
* outbound hop subtracts `now` from. Real monotonic time (`systemClock`), never an injected
|
|
28
|
+
* clock, for the reason the drain budget is: the timer beside it runs on `setTimeout`, so a
|
|
29
|
+
* frozen clock would publish an instant the abort will not honour.
|
|
30
|
+
*/
|
|
31
|
+
readonly deadlineAt: number | null;
|
|
19
32
|
/** Rejects with `X_TIMEOUT` at the deadline; `undefined` when there is no deadline. */
|
|
20
33
|
readonly expired: Promise<never> | undefined;
|
|
21
34
|
readonly timeoutMs: number;
|
|
@@ -27,6 +40,7 @@ const NEVER_ABORTED: AbortSignal = new AbortController().signal;
|
|
|
27
40
|
|
|
28
41
|
const NO_DEADLINE: Deadline = {
|
|
29
42
|
signal: NEVER_ABORTED,
|
|
43
|
+
deadlineAt: null,
|
|
30
44
|
expired: undefined,
|
|
31
45
|
timeoutMs: 0,
|
|
32
46
|
clear: () => undefined,
|
|
@@ -69,6 +83,8 @@ export const startDeadline = (input: {
|
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
const controller = new AbortController();
|
|
86
|
+
// Read BEFORE the timer is armed, so the published instant is never later than the abort.
|
|
87
|
+
const deadlineAt = systemClock.now().getTime() + timeoutMs;
|
|
72
88
|
let fire: (() => void) | undefined;
|
|
73
89
|
const expired = new Promise<never>((_resolve, reject) => {
|
|
74
90
|
fire = () => reject(requestTimedOut(input.method, input.pathname, timeoutMs));
|
|
@@ -83,6 +99,7 @@ export const startDeadline = (input: {
|
|
|
83
99
|
}, timeoutMs);
|
|
84
100
|
|
|
85
101
|
return {
|
|
102
|
+
deadlineAt,
|
|
86
103
|
// Both halves, or the doc on `ctx.signal` is half true — which it was: nothing in this package
|
|
87
104
|
// read the inbound signal, so a browser closing the tab left the request holding its pool slot
|
|
88
105
|
// and its vendor connection for the whole budget, for a caller that is gone. `expired` stays
|