@ultimat3/http 2.0.0 → 3.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
@@ -156,7 +156,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
156
156
  - Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
157
157
  table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
158
158
  `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.
159
+ every app code was 500 and `pipeline.ts` paged the on-call for a wrong password. There is
160
+ deliberately **no projection of the app's half**: `appErrorStatus()` was exported for "`x errors
161
+ list` and the manifest" and neither ever called it (deleted 2026-08). It could not have worked —
162
+ `APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
163
+ named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
164
+ Wiring one means deriving it from source, not re-exporting the map.
160
165
  - **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
161
166
  once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
162
167
  `UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
@@ -187,6 +192,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
187
192
  hit: the stage that renders a throw has nothing left to render its own. Every degraded answer goes
188
193
  *through* the recover stage, never around it — reporting, logging and the overlay each keep one
189
194
  call site.
195
+ - **Both guards in that tail are TOTAL against a throwable that fights being read** (`As of
196
+ 2026-08`). `recoverWith`'s catch built its log line with `String(failure)`, which is itself a
197
+ `TypeError` on a null-prototype object — thrown out of the one guard documented "never throws, by
198
+ construction", from the frame with nothing above it. It is a log FIELD now, the same rule the
199
+ `error-map` stage already follows, and `logger.emit` degrades a hostile field per key. The second
200
+ half is `factsOf`: it read `record['code']` directly, and that read is a getter call or a
201
+ `Proxy`'s `get` trap on a value the framework did not build — so a handler throwing one took the
202
+ recover stage AND the `problem()` the guard degrades to, and `handle()` rejected. Every field
203
+ comes off the throwable through core's `stringField`. Never spell either read inline again:
204
+ `String(x)`, `${x}` and a bare property read on a caught value are all the same defect, and
205
+ `error-render.ts` names seven prior instances.
190
206
  - **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
191
207
  The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
192
208
  IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "2.0.0",
3
+ "version": "3.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": "2.0.0",
35
- "@ultimat3/i18n": "2.0.0",
36
- "@ultimat3/schema": "2.0.0",
37
- "@ultimat3/time": "2.0.0"
34
+ "@ultimat3/core": "3.0.0",
35
+ "@ultimat3/i18n": "3.0.0",
36
+ "@ultimat3/schema": "3.0.0",
37
+ "@ultimat3/time": "3.0.0"
38
38
  }
39
39
  }
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
  /**
@@ -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,
@@ -228,10 +232,6 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
228
232
  /** Test seam. Production registers once at boot and never unregisters. */
229
233
  export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
230
234
 
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
235
  // Framework table first: `registerErrorStatus` already refuses those codes, so the order is
236
236
  // belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
237
237
  // even if a future caller reaches the map some other way.
@@ -250,35 +250,38 @@ export interface ErrorFacts {
250
250
  readonly stack: string | undefined;
251
251
  }
252
252
 
253
- const str = (source: Record<string, unknown>, key: string): string | undefined => {
254
- const value = source[key];
255
- return typeof value === 'string' && value.length > 0 ? value : undefined;
253
+ /**
254
+ * One string field off the throwable, through core's `stringField`. The read is a getter call —
255
+ * or a `Proxy`'s `get` trap on a value the framework did not build, and it throws in the one
256
+ * place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
257
+ * the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
258
+ * renderings and `handle()` rejected against its own contract.
259
+ */
260
+ const str = (source: unknown, key: string): string | undefined => {
261
+ const value = stringField(source, key);
262
+ return value !== undefined && value.length > 0 ? value : undefined;
256
263
  };
257
264
 
258
- const asRecord = (value: unknown): Record<string, unknown> =>
259
- typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
260
-
261
265
  /**
262
266
  * Normalises any throwable into the framework's error contract. Non-Ultimate
263
267
  * throwables still get a code and a fix, because "errors are instructions" has to
264
268
  * hold for the accidental `TypeError` too.
265
269
  */
266
270
  export const factsOf = (error: unknown): ErrorFacts => {
267
- const record = asRecord(error);
268
- const code = str(record, 'code') ?? 'X_INTERNAL';
271
+ const code = str(error, 'code') ?? 'X_INTERNAL';
269
272
  // The error's own title first: every `UltimateError` resolves one from the code registry at
270
273
  // construction, so this renders the OWNING package's title — including the codes http only
271
274
  // borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
272
275
  // Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
273
276
  const title =
274
- str(record, 'title') ??
277
+ str(error, 'title') ??
275
278
  HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
276
- str(record, 'message') ??
279
+ str(error, 'message') ??
277
280
  'unhandled server error';
278
281
  // The last fallback is the only one that touches the throwable whole, and every throwable a
279
282
  // request produces reaches it. `String()` runs the value's own `toString`, so the value that
280
283
  // took the request down took the 500 renderer with it and the server had nothing left to send.
281
- const cause = str(record, 'cause') ?? str(record, 'message') ?? renderCauseValue(error);
284
+ const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
282
285
  return {
283
286
  code,
284
287
  title,
@@ -286,11 +289,10 @@ export const factsOf = (error: unknown): ErrorFacts => {
286
289
  // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
287
290
  // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
288
291
  // `x errors explain` ships, and it is the command that answers "what is this code".
289
- fix:
290
- str(record, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
291
- docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
292
+ fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
293
+ docs: str(error, 'docs') ?? `https://ultimate.dev/errors/${code}`,
292
294
  status: statusFor(code),
293
- stack: str(record, 'stack'),
295
+ stack: str(error, 'stack'),
294
296
  };
295
297
  };
296
298
 
package/src/finalize.ts CHANGED
@@ -26,9 +26,14 @@ export const recoverWith =
26
26
  const rendered = await stage?.run(request, ctx);
27
27
  if (rendered !== undefined) return rendered;
28
28
  } catch (failure) {
29
- logger.error(
30
- `the recover stage threw and cannot render itself [${ctx.requestId}]: ${String(failure)}`,
31
- );
29
+ // FIELDS, never interpolation — and this is the file whose one promise is that it cannot
30
+ // itself throw. `String(failure)` runs the value's own coercion, so a null-prototype object
31
+ // (`Cannot convert object to primitive value`) or a `Proxy` threw a SECOND time out of the
32
+ // guard, and `handle`'s "always resolves to a Response" died in the one frame with nothing
33
+ // above it. `logger.emit` degrades a hostile field per key and never rethrows, which is
34
+ // exactly what a value nobody here built needs; it is also the shape `error-map` already
35
+ // uses, so the value stays redactable by key.
36
+ logger.error('pipeline.recover_failed', { requestId: ctx.requestId, error: failure });
32
37
  }
33
38
  return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
34
39
  };
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,