@ultimat3/http 11.3.0 → 13.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 +159 -4
- package/README.md +109 -6
- package/package.json +5 -5
- package/src/app-config.ts +146 -0
- package/src/config.ts +5 -2
- package/src/context.ts +65 -33
- package/src/cors.ts +2 -2
- package/src/deadline.ts +18 -1
- package/src/error-facts.ts +286 -0
- package/src/error-map.ts +130 -193
- package/src/errors.ts +46 -6
- package/src/index.ts +32 -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 +39 -0
- package/src/webhook-verify.ts +133 -0
package/CLAUDE.md
CHANGED
|
@@ -20,7 +20,28 @@ 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
|
-
-
|
|
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.
|
|
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
|
|
26
47
|
`TypeError: ctx.now is not a function` on every audited action served over HTTP,
|
|
@@ -30,6 +51,46 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
30
51
|
and it is a type-pin rather than a `.test.ts` because `tsconfig.json` excludes tests.
|
|
31
52
|
`ctx.buildId` is core's meaning — the build this PROCESS serves; the CLIENT's claim is
|
|
32
53
|
`ctx.clientBuildId`, read only by `assertBuild()`.
|
|
54
|
+
|
|
55
|
+
**`RequestContext extends Ctx` again, and this file no longer BUILDS a context — it composes
|
|
56
|
+
one** (`As of 2026-08-24`). `Ctx extends CtxServices`, and `CtxServices` is the seam an app
|
|
57
|
+
augments (`declare module '@ultimat3/core'`) to declare `ctx.posts` — so in an APP's program
|
|
58
|
+
every service it declared became a REQUIRED member of `createRequestContext`'s object literal,
|
|
59
|
+
and this file failed to compile inside `examples/dummy` with `TS2739: missing posts, orgs` while
|
|
60
|
+
the framework's own gate, which augments nothing, stayed green. The framework cannot set members
|
|
61
|
+
only the app's boot knows about.
|
|
62
|
+
|
|
63
|
+
`createRequestContext` now spreads `createContext()`'s result. Those members arrive WITH the
|
|
64
|
+
base, so the literal is checked in full and there is **no assertion left in this file** —
|
|
65
|
+
`withServices`, which existed for one release, is gone. `packages/core/src/context.ts` keeps the
|
|
66
|
+
framework's one irreducible `as Ctx` and its header states, with the four measured alternatives,
|
|
67
|
+
why it cannot be removed below a major.
|
|
68
|
+
|
|
69
|
+
Composing is also one constructor for one shape instead of two. This file used to re-derive
|
|
70
|
+
`clock`, `now`, the logger child, `signal`, `deadlineAt` and the service bag, so core could fix
|
|
71
|
+
any of them and this surface would keep the old answer — which is exactly what happened to the
|
|
72
|
+
bag (see the bullet below). `defineService` factories now install here too, for the same reason:
|
|
73
|
+
they are `createContext`'s, and this is `createContext`.
|
|
74
|
+
|
|
75
|
+
`type-pins.ts` carries `_RequestContextIsACtx`, because `asCtx` is a function body and a future
|
|
76
|
+
edit answering a failure there with a cast would delete the enforcement and leave the comment.
|
|
77
|
+
The reverse direction is FALSE by design — core's `Ctx` carries no `requestHeaders`, which is
|
|
78
|
+
what `assertInRequest` proves one way at runtime.
|
|
79
|
+
- **`defineService` used to be a job-and-CLI feature, and nothing said so** (`As of 2026-08-24`).
|
|
80
|
+
Two halves, and together they made services unreachable on the surface an app spends its life
|
|
81
|
+
on. This file built its own service bag from `RequestContextInit.services` alone and never
|
|
82
|
+
called core's `installedServices()`; `pipeline.ts:224` passes NO `services` at all. So a
|
|
83
|
+
`defineService('posts', …)` an app registered at boot was installed for a job, a task and a CLI
|
|
84
|
+
command and for nothing else — over HTTP `ctx.services` was `{}` and `useService('posts')` threw
|
|
85
|
+
`X_SERVICE_MISSING`. And the bag, even when one was passed, was never spread ONTO the context,
|
|
86
|
+
so `ctx.posts` — the spelling `docs/architecture/15-adding-a-feature.md` writes in its worked
|
|
87
|
+
example — read `undefined` beside a populated `ctx.services`.
|
|
88
|
+
|
|
89
|
+
Composing `createContext` fixes both at once, which is the argument for composing: the installer
|
|
90
|
+
is core's and so is the spread. Services go on FIRST so a service an app named `actor` or
|
|
91
|
+
`logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the
|
|
92
|
+
context's meaning never depends on what an app named a service. `context.test.ts` pins the
|
|
93
|
+
factory install, the spread and the collision order.
|
|
33
94
|
- **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
|
|
34
95
|
resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
|
|
35
96
|
parsed by a stage arrived one frame after the span's context was already frozen: the caller's
|
|
@@ -60,6 +121,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
60
121
|
with `AbortSignal.any`, which is what `context.ts` had documented and nothing wired — a closed
|
|
61
122
|
tab held its handler, its pool slot and its vendor connection for the whole 30s. `expired` stays
|
|
62
123
|
the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
|
|
124
|
+
**And it leaves this process on the next hop's headers, `As of 2026-08-24`**:
|
|
125
|
+
`Deadline.deadlineAt` is published as core's `ctx.deadlineAt`, and `traceHeaders()` (tier 0, the
|
|
126
|
+
one thing both typed clients spread before the caller's own headers) sends what is LEFT as
|
|
127
|
+
`x-request-timeout-ms`. Before that the header had exactly one reader — `resolveTimeoutMs`, in
|
|
128
|
+
this file — and **zero writers anywhere in the tree**, so gateway → A (30s) → B meant a call made
|
|
129
|
+
at t=29 started B on a FRESH 30s: real work, holding a pool slot and a vendor connection, half a
|
|
130
|
+
minute after A's socket was answered `X_TIMEOUT`. A spent budget sends no header at all rather
|
|
131
|
+
than `0`, because `resolveTimeoutMs` ignores anything under 1ms and falls back to its own.
|
|
63
132
|
With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared
|
|
64
133
|
never-aborted singleton, which every such request used to share — one `abort` listener per
|
|
65
134
|
request, accumulating for the life of the process. Always `deadline.clear()` in the `finally` —
|
|
@@ -95,6 +164,38 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
95
164
|
`error-map` stage is the one call site that can see the config, and every degraded `problem()` in
|
|
96
165
|
the tail must stay opaque. The real text is not lost — it is the log field and the error report,
|
|
97
166
|
both keyed by the request id the caller was given.
|
|
167
|
+
- **The problem document carries the ISSUE LIST, and the opacity rule applies to it**
|
|
168
|
+
(`As of 2026-08-24`). `ProblemDocument.issues` is a top-level extension member — RFC 9457 §3.2
|
|
169
|
+
puts extension members at the document root and every Ultimate extension already is one
|
|
170
|
+
(`code`, `cause`, `fix`, `docs`, `requestId`); there is no bag. `@ultimat3/action` has attached
|
|
171
|
+
the list to `meta.issues` since `InputInvalidError` grew its third parameter and **nothing
|
|
172
|
+
carried it**, so every app in this framework recovered per-field form errors by splitting
|
|
173
|
+
`cause` on `'; '` — guesswork the moment a message contains the separator.
|
|
174
|
+
|
|
175
|
+
Four rules, and the third is the one most likely to be dropped by a later edit.
|
|
176
|
+
`issuesOf` is TOTAL and module-private, written in `retryAfterOf`'s shape and for its reason:
|
|
177
|
+
`meta` is a property read on a value this package did not build, in the frame that decides what
|
|
178
|
+
the caller sees. It is **all-or-nothing** — a client that finds `issues` uses it INSTEAD of
|
|
179
|
+
`cause`, so one unreadable entry drops the whole list back to the prose line rather than
|
|
180
|
+
shipping a subset, which would be a rejection the user never sees and a form reporting itself
|
|
181
|
+
valid. The member is **absent** when there is none, never `undefined` and never `[]`:
|
|
182
|
+
`JSON.stringify` drops an `undefined`, but this interface is read directly by `error-page.ts`
|
|
183
|
+
and by tests, and `[]` claims "validated clean" about a request that was just refused — which
|
|
184
|
+
the first implementation of this reader emitted, because `Array.isArray([])` is true and the
|
|
185
|
+
loop simply does not run. A list past `MAX_PROBLEM_ISSUES` (100) is dropped WHOLE for the same
|
|
186
|
+
all-or-nothing reason, and because `@ultimat3/action`'s `issuesFromWire` bounds it identically
|
|
187
|
+
on arrival: sending it is a body that costs the wire and answers nothing. That package is tier 3,
|
|
188
|
+
so the number is restated here and pinned on this side. And it is **dropped under exactly the condition
|
|
189
|
+
that blanks `title`/`detail`/`cause`** — an issue list on an unclassified 5xx is precisely the
|
|
190
|
+
internal detail `INTERNAL_CAUSE` exists to withhold, because it names the fields and the
|
|
191
|
+
expectations of something the caller was never meant to see inside. `X_INPUT_INVALID` is a
|
|
192
|
+
declared 4xx and so is never opaque.
|
|
193
|
+
|
|
194
|
+
`received` is forced to `''` and every entry is rebuilt member by member, never spread. Not
|
|
195
|
+
redundancy with `toValidationIssues`, which forces the same thing: this is the boundary where
|
|
196
|
+
the value LEAVES the process, a conforming library's own issue object is first-class here and
|
|
197
|
+
routinely carries the rejected value, and `@ultimat3/schema`'s `describeValue` exists because a
|
|
198
|
+
password-strength rule once wrote mistyped passwords into the log index.
|
|
98
199
|
- **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
|
|
99
200
|
`contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
|
|
100
201
|
`error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
|
|
@@ -242,6 +343,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
242
343
|
`lastResort` spells its one `type` as a literal because that function calls nothing, and
|
|
243
344
|
`pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
|
|
244
345
|
cannot drift. Never assert either value as a copied string — import the constant.
|
|
346
|
+
- **`error-map.ts` answers the status; `error-facts.ts` renders the throwable** (`As of
|
|
347
|
+
2026-08-24`). One file did both and reached 501 lines, over the ceiling. The seam between
|
|
348
|
+
them is `declaredStatusFor(code)` — `number | undefined`, exported to this package only —
|
|
349
|
+
because the two questions are genuinely different: `statusFor` always answers a number, while
|
|
350
|
+
"did ANYBODY classify this code" is what decides whether a 5xx may carry the throwable's own
|
|
351
|
+
words back to the caller (`isUnclassifiedFailure`). Imports go one way, `error-facts.ts` →
|
|
352
|
+
`error-map.ts`; a status read from the facts file would be the second table this package
|
|
353
|
+
spent a release deleting.
|
|
245
354
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
246
355
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
247
356
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
@@ -309,8 +418,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
309
418
|
`X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
|
|
310
419
|
the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
|
|
311
420
|
not one.
|
|
421
|
+
- **One request spends a LIST of rate-limit keys, and the tenant's is the second** (`As of
|
|
422
|
+
2026-08-24`). `rateLimitKey` picked ONE subject — actor > org > ip, exclusive — and `actorView`
|
|
423
|
+
answers `null` for anonymous, so `orgId` was consulted only for a caller with an org and no id:
|
|
424
|
+
**no authenticated request ever touched an org bucket**. A tenant with 8,000 seats whose
|
|
425
|
+
integration entered a retry loop therefore took 8,000 × the per-actor burst against one shared
|
|
426
|
+
pool, every bucket inside its own limit, and no number an operator could set would have refused
|
|
427
|
+
it — while `@ultimat3/jobs` has had `perTenant` since it shipped. `rateLimitSpends` answers the
|
|
428
|
+
caller's key **and** `tenant|org:<id>` when the app declared `rateLimit.tenantBucket`; the stage
|
|
429
|
+
spends them in order and stops at the first refusal, so a caller its own bucket already refused
|
|
430
|
+
costs its tenant nothing. The tenant key is deliberately NOT scoped to the route — a per-route
|
|
431
|
+
tenant bucket is the same number multiplied by the route table, which is not a cap. `null` is
|
|
432
|
+
the default because one tenant is a person and the next is five thousand seats (axiom 8), and a
|
|
433
|
+
name nothing declares is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, never a
|
|
434
|
+
silent fall-through to `default`. The headers report the bucket **closest to refusing**: telling
|
|
435
|
+
a client `remaining: 99` off its own bucket while its tenant's holds 2 is a number that plans a
|
|
436
|
+
caller into a 429.
|
|
312
437
|
- **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 (`
|
|
438
|
+
The key falls back to the connection address (`rateLimitSpends`), so a scan rotating through an
|
|
314
439
|
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
315
440
|
entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
|
|
316
441
|
missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
|
|
@@ -378,6 +503,33 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
378
503
|
is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
|
|
379
504
|
caller who built their own may have meant their own numbers. A limiter that declares no table
|
|
380
505
|
is refused too — what cannot be shown to hold is not assumed to hold.
|
|
506
|
+
- **`verifyWebhookSignature` is a FUNCTION, never a pipeline stage** (`As of 2026-08-24`). The
|
|
507
|
+
secret is per SENDER, and only the route knows which sender it is serving; a stage would need one
|
|
508
|
+
secret for the whole app or a table this package has no business holding. It reads the body
|
|
509
|
+
through core's `readWithinLimit` — the same counting reader `UltimateRequest.#read` uses — so it
|
|
510
|
+
composes with the cap rather than defeating it, and it answers the raw text so the caller parses
|
|
511
|
+
the bytes that were signed rather than a re-serialisation of them.
|
|
512
|
+
|
|
513
|
+
The order inside it is load-bearing: the mac is checked BEFORE the freshness window, so
|
|
514
|
+
`X_WEBHOOK_SIGNATURE_STALE` means *authentic and old* and never *unreadable and old* — an
|
|
515
|
+
operator reading it goes to a clock or a replay, which is the only reason the second code exists.
|
|
516
|
+
The window is `Math.abs`, both directions: a sender whose clock runs ahead is the same replay
|
|
517
|
+
window pointed the other way, and accepting the future half doubles it. The timestamp is parsed
|
|
518
|
+
digits-only because `Number('nope')` is `NaN` and `NaN > toleranceMs` is FALSE — the one guard
|
|
519
|
+
whose failure mode is "the check does not run". `:` is refused in the id and the topic because
|
|
520
|
+
one mac over `v1:t:evt:01HZ:orders.paid:<body>` would otherwise authenticate two different
|
|
521
|
+
id/topic splits. The comparison is `timingSafeEqual` and may never become `===`; `bun run
|
|
522
|
+
secret-compare` is the mechanical half and `mac`/`signature` are names it reads.
|
|
523
|
+
|
|
524
|
+
**The FORMAT is `@ultimat3/core`'s and is not re-declared here** (`As of 2026-08-24`).
|
|
525
|
+
`packages/core/src/webhook-signature.ts` owns the canonical string, the mac and the parse;
|
|
526
|
+
this file owns the POLICY — what counts as fresh, how large a body may be, and which refusal a
|
|
527
|
+
receiver answers with. It shipped for one release as two implementations, here and in
|
|
528
|
+
`@ultimat3/jobs`, held together by a hex literal asserted in two test files: this package is
|
|
529
|
+
tier 2 and may not reach tier 3, that one's boundary forbids `http`, so the one copy lives at
|
|
530
|
+
the tier both can reach — the argument `timing-safe-equal.ts` makes for itself. The two literal
|
|
531
|
+
vectors stay until `scripts/webhook-round-trip.test.ts` replaces them. **Never re-declare the
|
|
532
|
+
canonical string here.**
|
|
381
533
|
- Never throw a bare `Error` — use a factory from `errors.ts`.
|
|
382
534
|
- No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
|
|
383
535
|
- Health endpoints answer outside the pipeline, on purpose.
|
|
@@ -402,12 +554,13 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
402
554
|
| `stages.ts` | what each stage DOES, one entry per `StageName`, plus the stage vocabulary the other two import |
|
|
403
555
|
| `finalize.ts` | the tail of that lifecycle, guarded: a throw after the handler degrades, never rejects |
|
|
404
556
|
| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
|
|
405
|
-
| `error-map.ts` | code → status table
|
|
557
|
+
| `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
|
|
558
|
+
| `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines |
|
|
406
559
|
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
|
|
407
560
|
| `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
|
|
408
561
|
| `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
|
|
409
562
|
| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
|
|
410
|
-
| `context.ts` | `RequestContext`
|
|
563
|
+
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `createContext`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
|
|
411
564
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
412
565
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
413
566
|
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
|
|
@@ -419,8 +572,10 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
419
572
|
| `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
|
|
420
573
|
| `deadline.ts` | the per-request `AbortController`, the timer and `X_TIMEOUT` |
|
|
421
574
|
| `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
|
|
575
|
+
| `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import |
|
|
422
576
|
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |
|
|
423
577
|
| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
|
|
578
|
+
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |
|
|
424
579
|
|
|
425
580
|
## Commands
|
|
426
581
|
|
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 |
|
|
@@ -79,6 +113,16 @@ thing that knows where its counters live, and a framework that inferred the answ
|
|
|
79
113
|
environment would get it wrong on the first deployment that scaled differently.
|
|
80
114
|
|
|
81
115
|
```ts
|
|
116
|
+
import {
|
|
117
|
+
createServer,
|
|
118
|
+
defineHttpConfig,
|
|
119
|
+
type RateLimitStore,
|
|
120
|
+
type Route,
|
|
121
|
+
} from '@ultimat3/http';
|
|
122
|
+
|
|
123
|
+
declare const routes: readonly Route[];
|
|
124
|
+
declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
|
|
125
|
+
|
|
82
126
|
createServer({
|
|
83
127
|
routes,
|
|
84
128
|
config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), // this limit is the fleet's
|
|
@@ -102,6 +146,18 @@ so nothing can be wrong.
|
|
|
102
146
|
`rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
|
|
103
147
|
maths stays in `createRateLimiter`, so every driver agrees on the numbers.
|
|
104
148
|
|
|
149
|
+
**One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
|
|
150
|
+
else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
|
|
151
|
+
builder used to pick exactly ONE subject and consult `orgId` only when there was no actor id,
|
|
152
|
+
which no authenticated request ever satisfies: a tenant with 8,000 seats took 8,000 × the
|
|
153
|
+
per-actor burst against one shared pool, every bucket inside its own limit, and no number an
|
|
154
|
+
operator could set would have refused it. The tenant key is `tenant|org:<id>` and is deliberately
|
|
155
|
+
NOT scoped to the route — a per-route tenant bucket is the same allowance once per route. The
|
|
156
|
+
spend stops at the first refusal, so a caller its own bucket refused costs its tenant nothing, and
|
|
157
|
+
the `ratelimit-*` headers report the bucket closest to refusing. `tenantBucket` defaults to `null`:
|
|
158
|
+
one tenant is a person and the next is five thousand seats, so there is no allowance a framework
|
|
159
|
+
can pick for you.
|
|
160
|
+
|
|
105
161
|
**A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table
|
|
106
162
|
and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,
|
|
107
163
|
`scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
|
|
@@ -188,6 +244,8 @@ flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** — a ro
|
|
|
188
244
|
cannot forget to declare its auth posture.
|
|
189
245
|
|
|
190
246
|
```ts
|
|
247
|
+
import { createServer, defineHttpConfig, json } from '@ultimat3/http';
|
|
248
|
+
|
|
191
249
|
const handle = createServer({
|
|
192
250
|
routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
|
|
193
251
|
handler: (req) => json({ id: req.param('id') }) }],
|
|
@@ -199,11 +257,56 @@ const handle = createServer({
|
|
|
199
257
|
Static paths are registered in Bun's native `routes` table; param/wildcard paths fall
|
|
200
258
|
through to `fetch`. Method resolution stays ours so a 405 still carries problem+json.
|
|
201
259
|
|
|
260
|
+
## Inbound webhooks
|
|
261
|
+
|
|
262
|
+
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
|
|
263
|
+
mechanism. It is a plain function and not a pipeline stage, because the secret is **per sender**
|
|
264
|
+
and only the route knows which one applies.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// apps/web/api/webhooks/partner/route.ts
|
|
268
|
+
import { verifyWebhookSignature } from '@ultimat3/http';
|
|
269
|
+
|
|
270
|
+
declare const env: { readonly PARTNER_WEBHOOK_SECRET: string }; // the app's defineEnv() result
|
|
271
|
+
// The seen-set and the dispatch are the app's — this package has nowhere to keep either.
|
|
272
|
+
declare function alreadyHandled(eventId: string): Promise<boolean>;
|
|
273
|
+
declare function handle(topic: string, payload: unknown, eventId: string): Promise<void>;
|
|
274
|
+
|
|
275
|
+
export async function POST(request: Request): Promise<Response> {
|
|
276
|
+
const { eventId, topic, body } = await verifyWebhookSignature(request, {
|
|
277
|
+
secret: env.PARTNER_WEBHOOK_SECRET,
|
|
278
|
+
});
|
|
279
|
+
// Parse the bytes that were SIGNED. Never `request.json()` — the stream is spent, and a
|
|
280
|
+
// re-serialisation would not be the bytes the mac covers.
|
|
281
|
+
const payload: unknown = JSON.parse(body);
|
|
282
|
+
if (await alreadyHandled(eventId)) return new Response(null, { status: 200 });
|
|
283
|
+
await handle(topic, payload, eventId);
|
|
284
|
+
return new Response(null, { status: 202 });
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
| Property | How |
|
|
289
|
+
|---|---|
|
|
290
|
+
| constant time | the mac is compared with `@ultimat3/core`'s `timingSafeEqual`, never `===` — where two macs first differ is exactly what a timing oracle forges one byte at a time |
|
|
291
|
+
| a replay expires | the signature's timestamp is checked against `toleranceMs` (5 minutes by default), **both** directions — a sender whose clock runs ahead is the same window pointed the other way |
|
|
292
|
+
| a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one |
|
|
293
|
+
| moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac |
|
|
294
|
+
| the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload |
|
|
295
|
+
| the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay |
|
|
296
|
+
|
|
297
|
+
`X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is
|
|
298
|
+
well formed and carried a credential, and the credential is what failed. Neither triggers the
|
|
299
|
+
sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
|
|
300
|
+
|
|
301
|
+
The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
|
|
302
|
+
canonical string is stated in both and pinned by one literal vector asserted in both test files.
|
|
303
|
+
|
|
202
304
|
## Errors
|
|
203
305
|
|
|
204
306
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
|
205
307
|
· `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
|
|
206
|
-
· `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED`
|
|
308
|
+
· `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED` · `X_WEBHOOK_SIGNATURE_INVALID`
|
|
309
|
+
· `X_WEBHOOK_SIGNATURE_STALE`
|
|
207
310
|
|
|
208
311
|
One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
|
|
209
312
|
overlay — so the `code`/`cause`/`fix` strings can never diverge.
|
|
@@ -217,7 +320,7 @@ in a table row and a table row has no anchor. Assert against `problemTypeFor` an
|
|
|
217
320
|
|
|
218
321
|
## Boundaries
|
|
219
322
|
|
|
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.
|
|
323
|
+
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
|
|
324
|
+
tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
|
|
325
|
+
`ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
|
|
326
|
+
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": "13.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": "13.0.0",
|
|
35
|
+
"@ultimat3/i18n": "13.0.0",
|
|
36
|
+
"@ultimat3/schema": "13.0.0",
|
|
37
|
+
"@ultimat3/time": "13.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';
|