@ultimat3/http 9.0.0 → 10.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
@@ -187,6 +187,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
187
187
  typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
188
188
  code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
189
189
  view and keeps `Object.hasOwn`.
190
+ - **A problem document's `type` and its `docs` are two different questions, `As of 2026-08-23`.**
191
+ Both used to be `https://ultimate.dev/errors/<code>` — one string, twice, and a host that
192
+ answers **404** on every 4xx and 5xx this package has ever rendered. `docs` is now core's
193
+ `ERROR_DOCS_URL`, one wiki page for every code, and it is never spelled here: a construction site
194
+ omits `docs:` and `UltimateError` resolves it. `type` did NOT follow it there. It is RFC 9457's
195
+ primary identifier for the problem KIND — a client switches on it — so collapsing it onto one
196
+ page would have given a 422 and a 403 the same identifier. `problemTypeFor(code)` answers
197
+ `urn:ultimate:error:<CODE>`: per code, stable, and a URN has no host to rot. `finalize.ts`'s
198
+ `lastResort` spells its one `type` as a literal because that function calls nothing, and
199
+ `pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
200
+ cannot drift. Never assert either value as a copied string — import the constant.
190
201
  - Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
191
202
  table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
192
203
  `registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
package/README.md CHANGED
@@ -205,6 +205,13 @@ through to `fetch`. Method resolution stays ours so a 405 still carries problem+
205
205
  One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
206
206
  overlay — so the `code`/`cause`/`fix` strings can never diverge.
207
207
 
208
+ A problem document's `type` and `docs` answer different questions and are two different
209
+ values. `type` is `problemTypeFor(code)` — `urn:ultimate:error:X_BODY_INVALID`, the RFC-9457
210
+ identifier a client switches on, per code, with no host to resolve or rot. `docs` is
211
+ `@ultimat3/core`'s `ERROR_DOCS_URL`, one wiki page for every code, because a code lives there
212
+ in a table row and a table row has no anchor. Assert against `problemTypeFor` and
213
+ `ERROR_DOCS_URL`, never against a copy of either string.
214
+
208
215
  ## Boundaries
209
216
 
210
217
  Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "9.0.0",
3
+ "version": "10.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": "9.0.0",
35
- "@ultimat3/i18n": "9.0.0",
36
- "@ultimat3/schema": "9.0.0",
37
- "@ultimat3/time": "9.0.0"
34
+ "@ultimat3/core": "10.0.0",
35
+ "@ultimat3/i18n": "10.0.0",
36
+ "@ultimat3/schema": "10.0.0",
37
+ "@ultimat3/time": "10.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, singleLine, stringField } from '@ultimat3/core';
4
+ import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
5
5
  import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
6
6
 
7
7
  /**
@@ -155,6 +155,13 @@ export const ERROR_STATUS = {
155
155
  // @ultimat3/policy
156
156
  X_POLICY_MISSING: 500,
157
157
  X_PERMISSION_UNKNOWN: 500,
158
+ // 500, and the page IS the point — deliberately not a 4xx to keep this table quiet.
159
+ // `enforce()` was handed a surface no adapter answers to, which reaches a request only through a
160
+ // config-driven route table, a surface name off the wire or a JS host; none of those is a value
161
+ // the caller can correct, and a 400 would tell them to fix a request that is not the problem.
162
+ // It is the third authz-dispatch fault beside the two rows above and takes their status for the
163
+ // same reason: the declaration is wrong, not the call.
164
+ X_POLICY_SURFACE_UNKNOWN: 500,
158
165
  // @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
159
166
  // paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
160
167
  // the client sends changes the answer, and the report to the on-call monitor is the point.
@@ -351,12 +358,58 @@ export const factsOf = (error: unknown): ErrorFacts => {
351
358
  // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
352
359
  // `x errors explain` ships, and it is the command that answers "what is this code".
353
360
  fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
354
- docs: str(error, 'docs') ?? `https://ultimate.dev/errors/${code}`,
361
+ // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
362
+ // and a code lives there in a table row, which has no anchor. An `UltimateError` already
363
+ // resolved this at construction, so the fallback only fires for a throwable the framework did
364
+ // not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
365
+ // 404 on every problem document this package has ever rendered.
366
+ docs: str(error, 'docs') ?? ERROR_DOCS_URL,
355
367
  status: statusFor(code),
356
368
  stack: str(error, 'stack'),
357
369
  };
358
370
  };
359
371
 
372
+ /**
373
+ * `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
374
+ *
375
+ * The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
376
+ * `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
377
+ * HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
378
+ * same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
379
+ * both told the caller to come back and never said when — which is the shed-with-no-delay pattern
380
+ * the `admit` stage exists to avoid, one layer in.
381
+ *
382
+ * Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
383
+ * not build, and it is read in the frame that decides what the caller sees.
384
+ */
385
+ export function retryAfterOf(error: unknown): number | undefined {
386
+ if (typeof error !== 'object' || error === null) return undefined;
387
+ try {
388
+ const meta: unknown = (error as Record<string, unknown>)['meta'];
389
+ if (typeof meta !== 'object' || meta === null) return undefined;
390
+ const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
391
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
392
+ // At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
393
+ // as "retry now", which is the stampede a Retry-After exists to spread.
394
+ return Math.max(1, Math.ceil(seconds));
395
+ } catch {
396
+ return undefined;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
402
+ * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
403
+ * to read about it, and those stopped being the same string when `docs` became one wiki page for
404
+ * every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
405
+ * forbidden the same identifier, which is the one thing a `type` may not do.
406
+ *
407
+ * A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
408
+ * — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
409
+ * `code` carries the same string as a plain member for a reader that would rather not parse a URI.
410
+ */
411
+ export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
412
+
360
413
  /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
361
414
  export interface ProblemDocument {
362
415
  readonly type: string;
@@ -377,7 +430,7 @@ export const toProblem = (
377
430
  ): ProblemDocument => {
378
431
  const facts = factsOf(error);
379
432
  return {
380
- type: facts.docs,
433
+ type: problemTypeFor(facts.code),
381
434
  title: facts.title,
382
435
  status: facts.status,
383
436
  detail: facts.cause,
package/src/errors.ts CHANGED
@@ -105,7 +105,12 @@ registerErrorRetry({
105
105
  X_OVERLOADED: 'retry-after',
106
106
  });
107
107
 
108
- const docsFor = (code: HttpErrorCode): string => `https://ultimate.dev/errors/${code}`;
108
+ // No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
109
+ // `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
110
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
111
+ // ROW, which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until
112
+ // 9.x answered 404, host included — and this package put them in `type` AND `docs` of every
113
+ // problem document, so the dead link was on every 4xx and 5xx an app has ever served.
109
114
 
110
115
  /** Base class for every error this package throws. Never throw a bare `Error`. */
111
116
  export class HttpError extends UltimateError {
@@ -126,7 +131,6 @@ export class HttpError extends UltimateError {
126
131
  code: init.code,
127
132
  cause: init.cause,
128
133
  fix: init.fix,
129
- docs: docsFor(init.code),
130
134
  ...(init.meta === undefined ? {} : { meta: init.meta }),
131
135
  });
132
136
  }
package/src/finalize.ts CHANGED
@@ -21,7 +21,10 @@ export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise
21
21
  const lastResort = (): Response =>
22
22
  new Response(
23
23
  JSON.stringify({
24
- type: 'https://ultimate.dev/errors/X_INTERNAL',
24
+ // Spelled out rather than built: this function calls nothing, which is what "last resort"
25
+ // means. `problemTypeFor('X_INTERNAL')` is the one it must equal, and
26
+ // `pipeline-finalize.test.ts` asserts that equality so the two cannot drift apart in silence.
27
+ type: 'urn:ultimate:error:X_INTERNAL',
25
28
  title: 'unhandled server error',
26
29
  status: 500,
27
30
  detail: 'the error renderer itself failed, so nothing of the original error survives here',
package/src/index.ts CHANGED
@@ -29,9 +29,11 @@ export {
29
29
  DEFAULT_STATUS,
30
30
  ERROR_STATUS,
31
31
  factsOf,
32
+ problemTypeFor,
32
33
  registerErrorStatus,
33
34
  renderErrorLines,
34
35
  resetErrorStatus,
36
+ retryAfterOf,
35
37
  statusFor,
36
38
  toProblem,
37
39
  } from './error-map';
package/src/rate-limit.ts CHANGED
@@ -326,9 +326,16 @@ export const createRateLimiter = (options: {
326
326
  const store = options.store ?? memoryRateLimitStore();
327
327
  const clock = options.clock ?? systemClock;
328
328
  const now = (): number => clock.now().getTime();
329
+ // `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and
330
+ // `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it
331
+ // holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of
332
+ // it, and a `Bucket` whose `capacity` is `undefined` is a limiter that decides nothing. Author-
333
+ // controlled, and still the one form — a table indexed by a name is indexed through `hasOwn`.
334
+ const declared = (name: string): Bucket | undefined =>
335
+ Object.hasOwn(options.config.buckets, name) ? options.config.buckets[name] : undefined;
329
336
  const bucketFor = (name: string): Bucket =>
330
- options.config.buckets[name] ??
331
- options.config.buckets[options.config.defaultBucket] ??
337
+ declared(name) ??
338
+ declared(options.config.defaultBucket) ??
332
339
  DEFAULT_RATE_LIMIT.buckets['default'] ?? { capacity: 60, refillPerSecond: 1 };
333
340
 
334
341
  const check: RateLimiter['check'] = (key, bucketName, cost = 1) =>
package/src/stages.ts CHANGED
@@ -19,7 +19,7 @@ import { type HttpConfig, stripBasePath } from './config';
19
19
  import { actorView, elapsedMs, type RequestContext } from './context';
20
20
  import { corsHeaders, preflight } from './cors';
21
21
  import { checkCsrf, selfOrigin } from './csrf';
22
- import { factsOf } from './error-map';
22
+ import { factsOf, retryAfterOf } from './error-map';
23
23
  import {
24
24
  bodyInvalid,
25
25
  csrfBlocked,
@@ -349,10 +349,22 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
349
349
  ...(notices.length === 0 ? {} : { notices }),
350
350
  });
351
351
  }
352
- const retryAfter =
353
- facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
354
- ? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
355
- : {};
352
+ // The limiter's own decision first — it is the live one and it knows this request's bucket —
353
+ // then whatever the THROWABLE computed. Only the first half existed, so every other refusal
354
+ // that had a delay to give told the caller to come back without saying when:
355
+ // `X_ACCOUNT_LOCKED` is a 429 with no `Retry-After` at all, and `X_OVERLOADED` from
356
+ // `@ultimat3/auth`'s KDF gate carries the number in `meta` under a comment saying the host
357
+ // reads it. Nothing was the host.
358
+ // `> 0` and not merely `!== undefined`: `RateLimitDecision.retryAfterSeconds` is `0` on an
359
+ // ALLOWED request, so a handler raising `X_RATE_LIMITED` from a limiter of its own — an
360
+ // action's declared `rateLimit`, `@ultimat3/auth`'s credential limiter — was answered
361
+ // `retry-after: 0`, which is "retry now" and is the stampede the header exists to spread.
362
+ const decided =
363
+ facts.code === 'X_RATE_LIMITED' && (ctx.rateLimit?.retryAfterSeconds ?? 0) > 0
364
+ ? ctx.rateLimit?.retryAfterSeconds
365
+ : undefined;
366
+ const seconds = decided ?? retryAfterOf(error);
367
+ const retryAfter = seconds === undefined ? {} : { 'retry-after': String(seconds) };
356
368
  return problem(error, {
357
369
  instance: ctx.url.pathname,
358
370
  requestId: ctx.requestId,