@ultimat3/http 3.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 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. Always `deadline.clear()` in the `finally` a live timer keeps the event
59
- loop from going idle, so a process that answered everything still refuses to exit.
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,6 +172,13 @@ 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,
@@ -203,6 +229,23 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
203
229
  comes off the throwable through core's `stringField`. Never spell either read inline again:
204
230
  `String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
205
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.
206
249
  - **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
207
250
  The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
208
251
  IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
@@ -211,6 +254,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
211
254
  and it evicts the entries **closest to full** first: throwing away a spent bucket is a free
212
255
  reset for whoever spent it, so the most-throttled key is the last one to go. Never swap that
213
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.
214
265
  - **Where the limiter's counters live is DECLARED by the app, never inferred, and refused at
215
266
  boot — and there is no default.** `DEFAULT_RATE_LIMIT` carries no `scope`, so
216
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.0.0",
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": "3.0.0",
35
- "@ultimat3/i18n": "3.0.0",
36
- "@ultimat3/schema": "3.0.0",
37
- "@ultimat3/time": "3.0.0"
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
- if (timeoutMs <= 0) return NO_DEADLINE;
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: controller.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
@@ -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: Readonly<Record<string, number>> = {
12
+ export const ERROR_STATUS = {
13
13
  // @ultimat3/http
14
14
  X_ROUTE_NOT_FOUND: 404,
15
15
  X_METHOD_NOT_ALLOWED: 405,
@@ -101,6 +101,12 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
101
101
  // the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
102
102
  // choosing "password" at a signup form was reported to the on-call monitor as a server fault.
103
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,
104
110
  // @ultimat3/entity
105
111
  X_NOT_FOUND: 404,
106
112
  X_ENTITY_DUPLICATE: 409,
@@ -126,6 +132,18 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
126
132
  // at it), which 422 describes only for the insert.
127
133
  X_DB_UNIQUE_VIOLATION: 409,
128
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,
129
147
  // @ultimat3/policy
130
148
  X_POLICY_MISSING: 500,
131
149
  X_PERMISSION_UNKNOWN: 500,
@@ -159,6 +177,11 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
159
177
  X_STORAGE_CHECKSUM_MISMATCH: 422,
160
178
  X_STORAGE_URL_INVALID: 403,
161
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,
162
185
  // 409, not the 500 it fell through to: the object exists and the request is well formed — the
163
186
  // STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
164
187
  // refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
@@ -189,10 +212,29 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
189
212
  X_TIMEOUT: 504,
190
213
  X_ABORTED: 499,
191
214
  X_INTERNAL: 500,
192
- };
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>>;
193
220
 
194
221
  export const DEFAULT_STATUS = 500;
195
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
+
196
238
  /**
197
239
  * Statuses for codes the APP owns. The table above is closed — it has to be, it is the
198
240
  * framework's own contract — and every code outside it fell to 500, so a wrong password was an
@@ -218,8 +260,12 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
218
260
  }
219
261
  // The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
220
262
  // to 200 would be an app whose 401 contract every client already depends on, changed.
221
- if (ERROR_STATUS[code] !== undefined) {
222
- throw errorStatusInvalid(code, `the framework already maps it to ${ERROR_STATUS[code]}`);
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}`);
223
269
  }
224
270
  const existing = APP_ERROR_STATUS.get(code);
225
271
  if (existing !== undefined && existing !== status) {
@@ -235,8 +281,10 @@ export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
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
- ERROR_STATUS[code] ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
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 {
@@ -275,7 +323,12 @@ export const factsOf = (error: unknown): ErrorFacts => {
275
323
  // Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
276
324
  const title =
277
325
  str(error, 'title') ??
278
- HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
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) ??
279
332
  str(error, 'message') ??
280
333
  'unhandled server error';
281
334
  // The last fallback is the only one that touches the throwable whole, and every throwable a
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
- export const bodyInvalid = (pathname: string, issues: readonly string[]): HttpError =>
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
@@ -35,7 +61,21 @@ export const recoverWith =
35
61
  // uses, so the value stays redactable by key.
36
62
  logger.error('pipeline.recover_failed', { requestId: ctx.requestId, error: failure });
37
63
  }
38
- return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
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();
78
+ }
39
79
  };
40
80
 
41
81
  /**
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
- now?: () => number;
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 now = options.now ?? (() => Date.now());
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
- throw bodyInvalid(this.pathname, [`could not parse ${type}: ${String(error)}`]);
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
- throw bodyInvalid(this.pathname, [`could not parse ${type}: ${String(error)}`]);
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
- throw bodyInvalid(this.pathname, [`unsupported content-type ${type}`]);
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
  }