@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 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 (`rateLimitKey`), so a scan rotating through an
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 + `factsOf()` |
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, `factsOf()` | `error-map.ts` |
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/schema` only. Authentication and
221
- policy evaluation arrive through `ServerHooks`, declared structurally, because
222
- `@ultimat3/policy` is a sibling tier. There is no plugin API: `Middleware` wraps a
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": "11.3.0",
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": "11.3.0",
35
- "@ultimat3/i18n": "11.3.0",
36
- "@ultimat3/schema": "11.3.0",
37
- "@ultimat3/time": "11.3.0"
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 HTTP slice of `app.config.ts`. One resolver, so a value is either a locked
2
- // default or an explicit override — never "whatever the first caller passed".
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
- // the app makes in app.config.ts, never something a route can quietly opt into.
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 const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
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