@ultimat3/http 2.0.0 → 4.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 +70 -3
- package/README.md +8 -0
- package/package.json +5 -5
- package/src/deadline.ts +19 -2
- package/src/error-map.ts +81 -26
- package/src/errors.ts +14 -1
- package/src/finalize.ts +49 -4
- package/src/index.ts +0 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit.ts +13 -2
- package/src/request.ts +24 -4
package/CLAUDE.md
CHANGED
|
@@ -55,8 +55,16 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
55
55
|
a caller may SHORTEN it with `x-request-timeout-ms`, never lengthen it. Two halves, both needed:
|
|
56
56
|
the abort is what cooperative code unwinds on, and the race in `execute` is what answers the
|
|
57
57
|
socket when a handler never looked at the signal. `X_TIMEOUT` is borrowed (core's concept) and
|
|
58
|
-
already mapped to 504.
|
|
59
|
-
|
|
58
|
+
already mapped to 504. **`ctx.signal` is the deadline OR the caller going away**, `As of
|
|
59
|
+
2026-08`: `pipeline.ts` hands `startDeadline` the inbound `Request.signal` and the two are joined
|
|
60
|
+
with `AbortSignal.any`, which is what `context.ts` had documented and nothing wired — a closed
|
|
61
|
+
tab held its handler, its pool slot and its vendor connection for the whole 30s. `expired` stays
|
|
62
|
+
the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
|
|
63
|
+
With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared
|
|
64
|
+
never-aborted singleton, which every such request used to share — one `abort` listener per
|
|
65
|
+
request, accumulating for the life of the process. Always `deadline.clear()` in the `finally` —
|
|
66
|
+
a live timer keeps the event loop from going idle, so a process that answered everything still
|
|
67
|
+
refuses to exit.
|
|
60
68
|
- **`admit` is the second stage, and it refuses before ANY work.** `isDraining()` had no reader in
|
|
61
69
|
this package while this file claimed the layer answered 503 on it; past `config.maxInflight`
|
|
62
70
|
(1000, `0` disables) a request is shed `X_OVERLOADED` with `retry-after`. Both set the header on
|
|
@@ -82,6 +90,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
82
90
|
and not reported and therefore kept for the full retention. The message is the CODE alone. The
|
|
83
91
|
other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
|
|
84
92
|
load-bearing one; this half is what makes the value redactable at all.
|
|
93
|
+
- **A rejected BODY is not a log field either — `bodyInvalid`'s `issues` may name only what the
|
|
94
|
+
framework chose** (`As of 2026-08-19`). `request.ts` built `could not parse ${type}: ${String(error)}`,
|
|
95
|
+
and the runtime's `SyntaxError` quotes the token it choked on: a `POST` of
|
|
96
|
+
`{"password": hunter2SuperSecret}` answered `422` with that identifier in `cause`, which goes to
|
|
97
|
+
the CALLER through `toProblem` and to the log store as the unredactable field `cause`. Two rules,
|
|
98
|
+
both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
|
|
99
|
+
JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
|
|
100
|
+
the caller supplied rides in `bodyInvalid`'s third argument, `meta`, which `toProblem` never
|
|
101
|
+
renders. The parser's own message goes through core's `renderThrowable`, never `String(error)`:
|
|
102
|
+
`bun run error-render` cannot see this class of defect, because a `catch` binding is not a
|
|
103
|
+
parameter, so it is a review rule here and a blind spot there.
|
|
85
104
|
- **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
|
|
86
105
|
One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
|
|
87
106
|
stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
|
|
@@ -153,10 +172,22 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
153
172
|
can never import each other, so the only conversion between `{ limit, windowMs }` and a `Bucket`
|
|
154
173
|
sitting in one of them is why a `query` could not declare a rate limit at all. It is beside
|
|
155
174
|
`Bucket` and the maths it validates, and it throws http's own `X_RATE_LIMIT_INVALID`.
|
|
175
|
+
- **`ERROR_STATUS`'s keys are LITERAL, not an index signature** (`As of 2026-08-19`). The
|
|
176
|
+
annotation was `Readonly<Record<string, number>>`, which made `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
177
|
+
a legal read answering `undefined` — a mistyped row in the one table the framework's whole error
|
|
178
|
+
contract rests on. It is now an object literal `satisfies Readonly<Record<string, number>>`, so a
|
|
179
|
+
typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
|
|
180
|
+
code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
|
|
181
|
+
view and keeps `Object.hasOwn`.
|
|
156
182
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
157
183
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
158
184
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
|
159
|
-
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password.
|
|
185
|
+
every app code was 500 and `pipeline.ts` paged the on-call for a wrong password. There is
|
|
186
|
+
deliberately **no projection of the app's half**: `appErrorStatus()` was exported for "`x errors
|
|
187
|
+
list` and the manifest" and neither ever called it (deleted 2026-08). It could not have worked —
|
|
188
|
+
`APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
|
|
189
|
+
named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
|
|
190
|
+
Wiring one means deriving it from source, not re-exporting the map.
|
|
160
191
|
- **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
|
|
161
192
|
once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
|
|
162
193
|
`UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
|
|
@@ -187,6 +218,34 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
187
218
|
hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
|
|
188
219
|
*through* the recover stage, never around it — reporting, logging and the overlay each keep one
|
|
189
220
|
call site.
|
|
221
|
+
- **Both guards in that tail are TOTAL against a throwable that fights being read** (`As of
|
|
222
|
+
2026-08`). `recoverWith`'s catch built its log line with `String(failure)`, which is itself a
|
|
223
|
+
`TypeError` on a null-prototype object — thrown out of the one guard documented "never throws, by
|
|
224
|
+
construction", from the frame with nothing above it. It is a log FIELD now, the same rule the
|
|
225
|
+
`error-map` stage already follows, and `logger.emit` degrades a hostile field per key. The second
|
|
226
|
+
half is `factsOf`: it read `record['code']` directly, and that read is a getter call or a
|
|
227
|
+
`Proxy`'s `get` trap on a value the framework did not build — so a handler throwing one took the
|
|
228
|
+
recover stage AND the `problem()` the guard degrades to, and `handle()` rejected. Every field
|
|
229
|
+
comes off the throwable through core's `stringField`. Never spell either read inline again:
|
|
230
|
+
`String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
|
|
231
|
+
`error-render.ts` names seven prior instances.
|
|
232
|
+
- **A table keyed by a `code` is read with `Object.hasOwn`, never `[code] !== undefined`** (`As of
|
|
233
|
+
2026-08`). `code` is a string off a throwable this package did not build, so `ERROR_STATUS` and
|
|
234
|
+
`HTTP_ERROR_TITLES` — object literals, and therefore holders of every name on
|
|
235
|
+
`Object.prototype` — answered `'toString'`, `'constructor'`, `'valueOf'` and `'hasOwnProperty'`
|
|
236
|
+
with a FUNCTION. `statusFor` handed that to `new Response(body, { status })`, a `RangeError`
|
|
237
|
+
raised inside `recoverWith`'s fallback, and `handle()` rejected: the same defect class as the
|
|
238
|
+
reads above, arriving through a lookup instead of a property. `registerErrorStatus` had the third
|
|
239
|
+
copy, refusing an app code named `toString` with a cause reading `the framework already maps it
|
|
240
|
+
to function toString() { [native code] }`. `scripts/error-map.ts` reads this table correctly and
|
|
241
|
+
always has. `APP_ERROR_STATUS` is a `Map`, which is why it never had the bug — prefer one for
|
|
242
|
+
anything keyed by a value a caller chose.
|
|
243
|
+
- **`recoverWith`'s fallback is INSIDE its `try`.** `return problem(ctx.error, …)` sat beside the
|
|
244
|
+
guard, so the file whose one promise is "never throws, by construction" rested on every reader
|
|
245
|
+
below that line being total. The degraded answer is a literal `problem+json` document naming
|
|
246
|
+
`X_INTERNAL`, built with no call that could fail in turn, and the renderer's own failure goes to
|
|
247
|
+
the log as `pipeline.problem_failed` — a last resort sharing a code path with what just broke is
|
|
248
|
+
not one.
|
|
190
249
|
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
|
|
191
250
|
The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
|
|
192
251
|
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
|
|
@@ -195,6 +254,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
195
254
|
and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
|
|
196
255
|
reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
|
|
197
256
|
comparator for insertion order or an LRU — recency is not the same as worthlessness here.
|
|
257
|
+
- **The limiter takes a `Clock`; `Date.now()` is not read here** (`As of 2026-08-19`). `rate-limit.ts`
|
|
258
|
+
read it inline while BOTH production call sites (`server.ts`, `pipeline.ts`) built their limiter
|
|
259
|
+
with no override, so the bucket maths that decides whether a caller is throttled could not be
|
|
260
|
+
frozen by any test — while `@ultimat3/auth`'s credential limiter has taken an injected `Clock`
|
|
261
|
+
since it shipped. `createRateLimiter({ clock })` defaults to `systemClock`, the same shape as
|
|
262
|
+
`createRequestContext`'s `init.clock`. Deliberately NOT a `clock` on `PipelineDeps`:
|
|
263
|
+
`deps.limiter` is already the one seam for handing the pipeline a limiter you built, and a second
|
|
264
|
+
entry point for one number is axiom 1.
|
|
198
265
|
- **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
|
|
199
266
|
boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
|
|
200
267
|
`resolveRateLimitConfig` refuses `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig` when a limiter
|
package/README.md
CHANGED
|
@@ -59,6 +59,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
59
59
|
| `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 |
|
|
60
60
|
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an `Origin` in `cors.origins` — anything else is refused before the body is read |
|
|
61
61
|
| a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
|
|
62
|
+
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
62
63
|
| a request while the process is draining | `X_DRAINING` (503) + `retry-after`, which is what `isDraining()` was always documented to do here and had no reader for |
|
|
63
64
|
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
64
65
|
|
|
@@ -99,6 +100,13 @@ so nothing can be wrong.
|
|
|
99
100
|
maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships
|
|
100
101
|
yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework.
|
|
101
102
|
|
|
103
|
+
The maths reads an injected `Clock`, defaulting to `systemClock`: `createRateLimiter({ config,
|
|
104
|
+
clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read
|
|
105
|
+
`Date.now()` when nothing passed one, which both production call sites did, so the limiter that
|
|
106
|
+
actually throttles a request could not be frozen. Replace `now: () => t` with
|
|
107
|
+
`clock: frozenClock(t)`; a limiter you build yourself still reaches the pipeline through
|
|
108
|
+
`PipelineDeps.limiter`, which stays the only seam for one.
|
|
109
|
+
|
|
102
110
|
### A route may bring its own bucket
|
|
103
111
|
|
|
104
112
|
`meta.rateLimit` names a bucket; `meta.rateLimitBucket` is the numbers that bucket must hold.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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": "4.0.0",
|
|
35
|
+
"@ultimat3/i18n": "4.0.0",
|
|
36
|
+
"@ultimat3/schema": "4.0.0",
|
|
37
|
+
"@ultimat3/time": "4.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/deadline.ts
CHANGED
|
@@ -52,9 +52,21 @@ export const startDeadline = (input: {
|
|
|
52
52
|
readonly config: HttpConfig;
|
|
53
53
|
readonly method: string;
|
|
54
54
|
readonly pathname: string;
|
|
55
|
+
/**
|
|
56
|
+
* The INBOUND `Request.signal` — the caller-went-away half of `ctx.signal`. Optional because a
|
|
57
|
+
* context can exist without a request (a job, a test), never because a server may skip it.
|
|
58
|
+
*/
|
|
59
|
+
readonly clientSignal?: AbortSignal | undefined;
|
|
55
60
|
}): Deadline => {
|
|
56
61
|
const timeoutMs = resolveTimeoutMs(input.headers, input.config);
|
|
57
|
-
|
|
62
|
+
const client = input.clientSignal;
|
|
63
|
+
// The caller's own signal, not the shared never-aborted one: with no deadline configured every
|
|
64
|
+
// request used to share a module-level singleton, so a handler that added an `abort` listener
|
|
65
|
+
// accumulated one per request for the life of the process — and no request could learn its
|
|
66
|
+
// caller had gone. `NO_DEADLINE` is left for the callers that genuinely have no client.
|
|
67
|
+
if (timeoutMs <= 0) {
|
|
68
|
+
return client === undefined ? NO_DEADLINE : { ...NO_DEADLINE, signal: client };
|
|
69
|
+
}
|
|
58
70
|
|
|
59
71
|
const controller = new AbortController();
|
|
60
72
|
let fire: (() => void) | undefined;
|
|
@@ -71,7 +83,12 @@ export const startDeadline = (input: {
|
|
|
71
83
|
}, timeoutMs);
|
|
72
84
|
|
|
73
85
|
return {
|
|
74
|
-
signal:
|
|
86
|
+
// Both halves, or the doc on `ctx.signal` is half true — which it was: nothing in this package
|
|
87
|
+
// read the inbound signal, so a browser closing the tab left the request holding its pool slot
|
|
88
|
+
// and its vendor connection for the whole budget, for a caller that is gone. `expired` stays
|
|
89
|
+
// the TIMER's alone: it is what answers the socket, and a socket the caller already closed has
|
|
90
|
+
// nothing to answer.
|
|
91
|
+
signal: client === undefined ? controller.signal : AbortSignal.any([client, controller.signal]),
|
|
75
92
|
expired,
|
|
76
93
|
timeoutMs,
|
|
77
94
|
clear: () => clearTimeout(timer),
|
package/src/error-map.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The one place a framework error code becomes an HTTP status. A table, not a
|
|
2
2
|
// switch chain: adding a code elsewhere in the framework means adding a row here,
|
|
3
3
|
// and a missing row is a loud 500 rather than a silently wrong 200.
|
|
4
|
-
import { renderCauseValue } from '@ultimat3/core';
|
|
4
|
+
import { renderCauseValue, stringField } from '@ultimat3/core';
|
|
5
5
|
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -9,7 +9,7 @@ import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
|
9
9
|
* is the only layer that knows what a status means, so no other package should
|
|
10
10
|
* ever hardcode one.
|
|
11
11
|
*/
|
|
12
|
-
export const ERROR_STATUS
|
|
12
|
+
export const ERROR_STATUS = {
|
|
13
13
|
// @ultimat3/http
|
|
14
14
|
X_ROUTE_NOT_FOUND: 404,
|
|
15
15
|
X_METHOD_NOT_ALLOWED: 405,
|
|
@@ -31,6 +31,10 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
31
31
|
// and declaring a status the framework already owns. 500 is the honest answer to either.
|
|
32
32
|
X_NO_REQUEST: 500,
|
|
33
33
|
X_ERROR_STATUS_INVALID: 500,
|
|
34
|
+
// A `hive()` whose `split()` returned no members. The caller cannot fix it by sending
|
|
35
|
+
// different input — the guard belongs in the app, either by returning at least one member
|
|
36
|
+
// or by skipping the hive when the source is empty — so it is the server's bug, not theirs.
|
|
37
|
+
X_HIVE_EMPTY: 500,
|
|
34
38
|
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
35
39
|
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
36
40
|
X_CORS_CONFIG_INVALID: 500,
|
|
@@ -97,6 +101,12 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
97
101
|
// the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
|
|
98
102
|
// choosing "password" at a signup form was reported to the on-call monitor as a server fault.
|
|
99
103
|
X_PASSWORD_WEAK: 422,
|
|
104
|
+
// 422 for the same reason as the row above, and deliberately not 500: `enrolTotp` throws it for a
|
|
105
|
+
// secret the CALLER supplied — an import from another MFA system, or a value off a form — and
|
|
106
|
+
// what failed is that value's content, not the server. `verifyTotp` never throws it (an
|
|
107
|
+
// unreadable stored secret is a non-verdict there, the rule `verifyAgainst` follows for a hash
|
|
108
|
+
// Bun cannot read), so a login checking a broken row cannot reach this status at all.
|
|
109
|
+
X_MFA_SECRET_INVALID: 422,
|
|
100
110
|
// @ultimat3/entity
|
|
101
111
|
X_NOT_FOUND: 404,
|
|
102
112
|
X_ENTITY_DUPLICATE: 409,
|
|
@@ -122,6 +132,18 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
122
132
|
// at it), which 422 describes only for the insert.
|
|
123
133
|
X_DB_UNIQUE_VIOLATION: 409,
|
|
124
134
|
X_DB_FOREIGN_KEY_VIOLATION: 409,
|
|
135
|
+
// @ultimat3/jobs — the ONE jobs code with a row here, and the reason the rest are pinned in
|
|
136
|
+
// `scripts/error-map-backlog.ts` does not cover it. That pin says "a job runs with no socket
|
|
137
|
+
// attached; `ROLE=worker` opens no HTTP port at all" — true of `X_JOB_TIMEOUT` and every other
|
|
138
|
+
// worker-runtime code, and NOT true of a decode failure: `toJobRecord` runs wherever a row is
|
|
139
|
+
// READ, which includes the admin dashboard's job panel and `x jobs show` served over HTTP.
|
|
140
|
+
// 500, and it should page: a queue holding rows this build cannot read is an operator's
|
|
141
|
+
// problem, and nothing the caller sent is wrong.
|
|
142
|
+
X_JOB_ROW_STATUS_UNKNOWN: 500,
|
|
143
|
+
// Thrown by `registerJobs()` while the app's modules load, so no request is ever answered with
|
|
144
|
+
// it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
|
|
145
|
+
// closed one, and a code with no row is a 500 anyway.
|
|
146
|
+
X_ACTION_JOB_UNBRIDGED: 500,
|
|
125
147
|
// @ultimat3/policy
|
|
126
148
|
X_POLICY_MISSING: 500,
|
|
127
149
|
X_PERMISSION_UNKNOWN: 500,
|
|
@@ -155,6 +177,11 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
155
177
|
X_STORAGE_CHECKSUM_MISMATCH: 422,
|
|
156
178
|
X_STORAGE_URL_INVALID: 403,
|
|
157
179
|
X_STORAGE_URL_EXPIRED: 410,
|
|
180
|
+
// 500, not 403, and the distinction is the whole reason this code exists rather than
|
|
181
|
+
// reusing X_STORAGE_URL_INVALID: nothing is wrong with the caller's URL. The disk was
|
|
182
|
+
// built with no way to check a signature, which is the operator's misconfiguration and
|
|
183
|
+
// not an attacker — reporting it as 403 sends the on-call hunting somebody who is not there.
|
|
184
|
+
X_STORAGE_URL_UNVERIFIABLE: 500,
|
|
158
185
|
// 409, not the 500 it fell through to: the object exists and the request is well formed — the
|
|
159
186
|
// STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
|
|
160
187
|
// refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
|
|
@@ -185,10 +212,29 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
|
185
212
|
X_TIMEOUT: 504,
|
|
186
213
|
X_ABORTED: 499,
|
|
187
214
|
X_INTERNAL: 500,
|
|
188
|
-
|
|
215
|
+
// The keys are LITERAL — deliberately not `Readonly<Record<string, number>>`, which is what the
|
|
216
|
+
// annotation used to say. This table is the closed one, so `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
217
|
+
// has to be a compile error rather than an `undefined` a test then asserts `toBeNumber()` on.
|
|
218
|
+
// Read it by a code the framework did not mint through `statusFor`, never by index.
|
|
219
|
+
} satisfies Readonly<Record<string, number>>;
|
|
189
220
|
|
|
190
221
|
export const DEFAULT_STATUS = 500;
|
|
191
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
|
|
225
|
+
*
|
|
226
|
+
* `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
|
|
227
|
+
* object literal, so it holds every name on `Object.prototype`: an app throwing
|
|
228
|
+
* `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
|
|
229
|
+
* `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
|
|
230
|
+
* one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
|
|
231
|
+
* `scripts/error-map.ts` reads the same table this way already.
|
|
232
|
+
*/
|
|
233
|
+
const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
|
|
234
|
+
|
|
235
|
+
const frameworkStatus = (code: string): number | undefined =>
|
|
236
|
+
Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
|
|
237
|
+
|
|
192
238
|
/**
|
|
193
239
|
* Statuses for codes the APP owns. The table above is closed — it has to be, it is the
|
|
194
240
|
* framework's own contract — and every code outside it fell to 500, so a wrong password was an
|
|
@@ -214,8 +260,12 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
|
|
|
214
260
|
}
|
|
215
261
|
// The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
|
|
216
262
|
// to 200 would be an app whose 401 contract every client already depends on, changed.
|
|
217
|
-
|
|
218
|
-
|
|
263
|
+
// Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
|
|
264
|
+
// own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
|
|
265
|
+
// framework already maps it to function toString() { [native code] }`.
|
|
266
|
+
const framework = frameworkStatus(code);
|
|
267
|
+
if (framework !== undefined) {
|
|
268
|
+
throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
|
|
219
269
|
}
|
|
220
270
|
const existing = APP_ERROR_STATUS.get(code);
|
|
221
271
|
if (existing !== undefined && existing !== status) {
|
|
@@ -228,15 +278,13 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
|
|
|
228
278
|
/** Test seam. Production registers once at boot and never unregisters. */
|
|
229
279
|
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
230
280
|
|
|
231
|
-
/** Every status the app declared, for `x errors list` and the manifest. */
|
|
232
|
-
export const appErrorStatus = (): Readonly<Record<string, number>> =>
|
|
233
|
-
Object.fromEntries([...APP_ERROR_STATUS].sort(([a], [b]) => a.localeCompare(b)));
|
|
234
|
-
|
|
235
281
|
// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
236
282
|
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
237
283
|
// even if a future caller reaches the map some other way.
|
|
284
|
+
// `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
285
|
+
// prefer one for anything keyed by a value a caller chose.
|
|
238
286
|
export const statusFor = (code: string): number =>
|
|
239
|
-
|
|
287
|
+
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
|
|
240
288
|
|
|
241
289
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
242
290
|
export interface ErrorFacts {
|
|
@@ -250,35 +298,43 @@ export interface ErrorFacts {
|
|
|
250
298
|
readonly stack: string | undefined;
|
|
251
299
|
}
|
|
252
300
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
301
|
+
/**
|
|
302
|
+
* One string field off the throwable, through core's `stringField`. The read is a getter call —
|
|
303
|
+
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
|
|
304
|
+
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
|
|
305
|
+
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
|
|
306
|
+
* renderings and `handle()` rejected against its own contract.
|
|
307
|
+
*/
|
|
308
|
+
const str = (source: unknown, key: string): string | undefined => {
|
|
309
|
+
const value = stringField(source, key);
|
|
310
|
+
return value !== undefined && value.length > 0 ? value : undefined;
|
|
256
311
|
};
|
|
257
312
|
|
|
258
|
-
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
259
|
-
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
260
|
-
|
|
261
313
|
/**
|
|
262
314
|
* Normalises any throwable into the framework's error contract. Non-Ultimate
|
|
263
315
|
* throwables still get a code and a fix, because "errors are instructions" has to
|
|
264
316
|
* hold for the accidental `TypeError` too.
|
|
265
317
|
*/
|
|
266
318
|
export const factsOf = (error: unknown): ErrorFacts => {
|
|
267
|
-
const
|
|
268
|
-
const code = str(record, 'code') ?? 'X_INTERNAL';
|
|
319
|
+
const code = str(error, 'code') ?? 'X_INTERNAL';
|
|
269
320
|
// The error's own title first: every `UltimateError` resolves one from the code registry at
|
|
270
321
|
// construction, so this renders the OWNING package's title — including the codes http only
|
|
271
322
|
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
|
|
272
323
|
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
273
324
|
const title =
|
|
274
|
-
str(
|
|
275
|
-
|
|
276
|
-
|
|
325
|
+
str(error, 'title') ??
|
|
326
|
+
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
|
|
327
|
+
// function off the prototype and put it in `title`, which is rendered into the problem
|
|
328
|
+
// document and the terminal.
|
|
329
|
+
(Object.hasOwn(HTTP_ERROR_TITLES, code)
|
|
330
|
+
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
|
|
331
|
+
: undefined) ??
|
|
332
|
+
str(error, 'message') ??
|
|
277
333
|
'unhandled server error';
|
|
278
334
|
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
279
335
|
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
|
|
280
336
|
// took the request down took the 500 renderer with it and the server had nothing left to send.
|
|
281
|
-
const cause = str(
|
|
337
|
+
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
|
|
282
338
|
return {
|
|
283
339
|
code,
|
|
284
340
|
title,
|
|
@@ -286,11 +342,10 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
286
342
|
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
287
343
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
288
344
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
289
|
-
fix:
|
|
290
|
-
|
|
291
|
-
docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
345
|
+
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
346
|
+
docs: str(error, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
292
347
|
status: statusFor(code),
|
|
293
|
-
stack: str(
|
|
348
|
+
stack: str(error, 'stack'),
|
|
294
349
|
};
|
|
295
350
|
};
|
|
296
351
|
|
package/src/errors.ts
CHANGED
|
@@ -160,10 +160,23 @@ export const pathInvalid = (pathname: string, segment: string): HttpError =>
|
|
|
160
160
|
fix: 'send the segment percent-encoded — encodeURIComponent(value); a literal % is %25',
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
/**
|
|
164
|
+
* `issues` is the CALLER-facing half and must name only facts the framework itself chose — a
|
|
165
|
+
* schema rule, a byte count, a content-type the router supports. Anything the caller sent goes in
|
|
166
|
+
* `meta`, which `toProblem` never renders and `stages.ts` never writes into the log message: a
|
|
167
|
+
* `cause` reaches the log store as an unredactable field AND the problem document, so a value
|
|
168
|
+
* baked into it has no key left to redact. The runtime's `SyntaxError` quotes the token it choked
|
|
169
|
+
* on, which is how a fragment of `{"password": …}` used to travel in both directions at once.
|
|
170
|
+
*/
|
|
171
|
+
export const bodyInvalid = (
|
|
172
|
+
pathname: string,
|
|
173
|
+
issues: readonly string[],
|
|
174
|
+
meta?: Readonly<Record<string, unknown>>,
|
|
175
|
+
): HttpError =>
|
|
164
176
|
new HttpError({
|
|
165
177
|
code: 'X_BODY_INVALID',
|
|
166
178
|
cause: `${pathname} body rejected: ${issues.join('; ')}`,
|
|
179
|
+
...(meta === undefined ? {} : { meta }),
|
|
167
180
|
// `x schema show` is not a command — not in the registry and not in `PLANNED_COMMANDS`, so it
|
|
168
181
|
// exits `X_CLI_UNKNOWN_COMMAND`. The same axiom-4 inversion `x logs tail` had in `error-map`:
|
|
169
182
|
// the one instruction the reader is given fails when they run it. `x routes` ships, and
|
package/src/finalize.ts
CHANGED
|
@@ -12,6 +12,32 @@ import type { Stage } from './stages';
|
|
|
12
12
|
/** Renders whatever sits on `ctx.error` into a Response. Never throws, by construction. */
|
|
13
13
|
export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise<Response>;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The answer when even rendering the problem document failed. Literals and one `new Response`,
|
|
17
|
+
* calling nothing that could fail in turn — a last resort that shares a code path with the thing
|
|
18
|
+
* that just broke is not one. Restating the RFC-9457 shape here is the cost of that: the client
|
|
19
|
+
* still gets a document its parser understands, with a code it can look up.
|
|
20
|
+
*/
|
|
21
|
+
const lastResort = (): Response =>
|
|
22
|
+
new Response(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
type: 'https://ultimate.dev/errors/X_INTERNAL',
|
|
25
|
+
title: 'unhandled server error',
|
|
26
|
+
status: 500,
|
|
27
|
+
detail: 'the error renderer itself failed, so nothing of the original error survives here',
|
|
28
|
+
code: 'X_INTERNAL',
|
|
29
|
+
cause: 'the error renderer itself failed, so nothing of the original error survives here',
|
|
30
|
+
fix: 'grep the logs for pipeline.problem_failed — it carries the renderer failure this reply could not',
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
status: 500,
|
|
34
|
+
headers: {
|
|
35
|
+
'content-type': 'application/problem+json; charset=utf-8',
|
|
36
|
+
'cache-control': 'no-store',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
15
41
|
/**
|
|
16
42
|
* The recover stage is the single place a throw becomes a status — so a throw INSIDE it (an app's
|
|
17
43
|
* `onError` sink, a `devNotices` producer) has nothing left to render it. Rethrowing would break
|
|
@@ -26,11 +52,30 @@ export const recoverWith =
|
|
|
26
52
|
const rendered = await stage?.run(request, ctx);
|
|
27
53
|
if (rendered !== undefined) return rendered;
|
|
28
54
|
} catch (failure) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
55
|
+
// FIELDS, never interpolation — and this is the file whose one promise is that it cannot
|
|
56
|
+
// itself throw. `String(failure)` runs the value's own coercion, so a null-prototype object
|
|
57
|
+
// (`Cannot convert object to primitive value`) or a `Proxy` threw a SECOND time out of the
|
|
58
|
+
// guard, and `handle`'s "always resolves to a Response" died in the one frame with nothing
|
|
59
|
+
// above it. `logger.emit` degrades a hostile field per key and never rethrows, which is
|
|
60
|
+
// exactly what a value nobody here built needs; it is also the shape `error-map` already
|
|
61
|
+
// uses, so the value stays redactable by key.
|
|
62
|
+
logger.error('pipeline.recover_failed', { requestId: ctx.requestId, error: failure });
|
|
63
|
+
}
|
|
64
|
+
// INSIDE a guard, not beside it — and that is this file's whole promise. The line renders a
|
|
65
|
+
// value nobody here built, so "never throws" rested on every reader below it being total, and
|
|
66
|
+
// one was not: `statusFor` read `ERROR_STATUS['toString']` off the prototype chain and handed
|
|
67
|
+
// `new Response(body, { status })` a function, so an app throwing `{ code: 'toString' }` made
|
|
68
|
+
// `handle()` REJECT from the one frame with nothing above it. That read is fixed in
|
|
69
|
+
// `error-map.ts`; this guard is what keeps the contract from depending on the next one.
|
|
70
|
+
try {
|
|
71
|
+
return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
72
|
+
} catch (failure) {
|
|
73
|
+
// No `ctx` read in this branch, deliberately: it is reached because reading `ctx` or the
|
|
74
|
+
// value on it threw, so reaching for one more field is how a guard throws out of the guard.
|
|
75
|
+
// `logger.emit` degrades a hostile field per key and never rethrows.
|
|
76
|
+
logger.error('pipeline.problem_failed', { error: failure });
|
|
77
|
+
return lastResort();
|
|
32
78
|
}
|
|
33
|
-
return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
34
79
|
};
|
|
35
80
|
|
|
36
81
|
/**
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,6 @@ export type { Deadline } from './deadline';
|
|
|
25
25
|
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
26
26
|
export type { ErrorFacts, ProblemDocument } from './error-map';
|
|
27
27
|
export {
|
|
28
|
-
appErrorStatus,
|
|
29
28
|
DEFAULT_STATUS,
|
|
30
29
|
ERROR_STATUS,
|
|
31
30
|
factsOf,
|
package/src/pipeline.ts
CHANGED
|
@@ -210,6 +210,10 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
210
210
|
config,
|
|
211
211
|
method: raw.method.toUpperCase(),
|
|
212
212
|
pathname: url.pathname,
|
|
213
|
+
// The caller-went-away half of `ctx.signal`, which `context.ts` documented and nothing
|
|
214
|
+
// wired: without it a closed tab kept its handler, its pool slot and its vendor
|
|
215
|
+
// connection alive for the whole `requestTimeoutMs`.
|
|
216
|
+
clientSignal: raw.signal,
|
|
213
217
|
});
|
|
214
218
|
const forwarded = {
|
|
215
219
|
headers: raw.headers,
|
package/src/rate-limit.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// dev/tests and against a shared tier in a multi-replica deployment — installed through
|
|
3
3
|
// `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
|
|
4
4
|
// declaration; the bucket maths lives here so every driver agrees on the numbers.
|
|
5
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
5
6
|
import { rateLimited, rateLimitInvalid, rateLimitNotShared, rateLimitScopeUnset } from './errors';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -289,10 +290,20 @@ export interface RateLimiter {
|
|
|
289
290
|
export const createRateLimiter = (options: {
|
|
290
291
|
config: RateLimitConfig;
|
|
291
292
|
store?: RateLimitStore;
|
|
292
|
-
|
|
293
|
+
/**
|
|
294
|
+
* The one source of "now" for the bucket maths. `Date.now()` used to be read inline here, and
|
|
295
|
+
* BOTH production call sites (`server.ts`, `pipeline.ts`) build their limiter without an
|
|
296
|
+
* override — so the limiter that actually throttles requests could not be frozen by any test,
|
|
297
|
+
* while `@ultimat3/auth`'s credential limiter has taken an injected `Clock` since it shipped.
|
|
298
|
+
* Defaulted rather than required, the same shape as `createRequestContext`'s `init.clock`;
|
|
299
|
+
* `PipelineDeps.limiter` stays the one seam for handing the pipeline a limiter of your own,
|
|
300
|
+
* because a second `clock` beside it would be a second way to set one number.
|
|
301
|
+
*/
|
|
302
|
+
clock?: Clock;
|
|
293
303
|
}): RateLimiter => {
|
|
294
304
|
const store = options.store ?? memoryRateLimitStore();
|
|
295
|
-
const
|
|
305
|
+
const clock = options.clock ?? systemClock;
|
|
306
|
+
const now = (): number => clock.now().getTime();
|
|
296
307
|
const bucketFor = (name: string): Bucket =>
|
|
297
308
|
options.config.buckets[name] ??
|
|
298
309
|
options.config.buckets[options.config.defaultBucket] ??
|
package/src/request.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// context so they cannot drift from what the pipeline resolved.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
-
import { readWithinLimit } from '@ultimat3/core';
|
|
6
|
+
import { readWithinLimit, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
8
|
import { bodyInvalid, buildSkew } from './errors';
|
|
9
9
|
import { readCookie } from './locale';
|
|
@@ -186,7 +186,12 @@ export class UltimateRequest {
|
|
|
186
186
|
}).formData();
|
|
187
187
|
return Object.fromEntries(form);
|
|
188
188
|
} catch (error) {
|
|
189
|
-
|
|
189
|
+
// The parser's own message is a diagnostic, not an instruction, and it quotes the bytes
|
|
190
|
+
// it choked on — so it rides in `meta`, rendered by core rather than by `String(error)`,
|
|
191
|
+
// which is itself a `TypeError` on a null-prototype throwable.
|
|
192
|
+
throw bodyInvalid(this.pathname, ['could not parse multipart/form-data'], {
|
|
193
|
+
parseError: renderThrowable(error),
|
|
194
|
+
});
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
|
|
@@ -198,8 +203,23 @@ export class UltimateRequest {
|
|
|
198
203
|
}
|
|
199
204
|
if (type.startsWith('text/')) return body;
|
|
200
205
|
} catch (error) {
|
|
201
|
-
|
|
206
|
+
// `JSON.parse` is the only thing above that throws — `new URLSearchParams(s)` and the
|
|
207
|
+
// `text/` branch accept any string — so the caller-facing half can name the format without
|
|
208
|
+
// echoing the `content-type` header the caller chose.
|
|
209
|
+
throw bodyInvalid(this.pathname, ['could not parse the body as JSON'], {
|
|
210
|
+
parseError: renderThrowable(error),
|
|
211
|
+
contentType: type,
|
|
212
|
+
});
|
|
202
213
|
}
|
|
203
|
-
|
|
214
|
+
// The list of what IS accepted, which is the actionable half; the value the caller sent is
|
|
215
|
+
// theirs already and is a log field here rather than a string in the message.
|
|
216
|
+
throw bodyInvalid(
|
|
217
|
+
this.pathname,
|
|
218
|
+
[
|
|
219
|
+
'content-type is not one of application/json, application/x-www-form-urlencoded, ' +
|
|
220
|
+
'multipart/form-data, text/*',
|
|
221
|
+
],
|
|
222
|
+
{ contentType: type },
|
|
223
|
+
);
|
|
204
224
|
}
|
|
205
225
|
}
|