@ultimat3/http 21.0.0 → 22.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,7 @@ asserts the order; `/_x` renders it. Ordering rules worth restating:
67
67
 
68
68
  | Rule | Reason |
69
69
  |---|---|
70
- | admit second | a draining or saturated process refuses before any work — no route match, no auth, no body |
70
+ | admit second | a stopped or saturated process refuses before any work — no route match, no auth, no body; a DRAINING one serves and closes the connection |
71
71
  | auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
72
72
  | csrf after auth | only a caller holding an AMBIENT credential can be forged into; bearer and anonymous are exempt |
73
73
  | csrf before body | a forged write never makes the server allocate its payload |
@@ -80,7 +80,8 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
80
80
  | Guard | Answer |
81
81
  |---|---|
82
82
  | a body past `bodyLimitBytes` | read through the stream and abandoned the instant the running total crosses the limit — `content-length` or not, multipart included — as `X_BODY_INVALID` |
83
- | a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie, x-timezone`. **Whatever the handler wrote**, `As of 2026-08-23`: the `cache-headers` stage REVIEWS a declared `cache-control` instead of standing down, because `@ultimat3/render`'s `ssrHeaders` offers every page without a `policy` to a CDN for 30s. An `immutable` answer is left alone — a content-addressed body is a function of its URL |
83
+ | a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie, x-timezone`. **Whatever the handler wrote**, `As of 2026-08-23`: the `cache-headers` stage REVIEWS a declared `cache-control` instead of standing down, because `@ultimat3/render`'s `ssrHeaders` offers every page without a `policy` to a CDN for 30s. An `immutable` answer is left alone — a content-addressed body is a function of its URL. **A declared hint too**, `As of 2026-09-23`: `meta.cache` / `ctx.cache` with `mode: 'public'` becomes `private` for a signed-in actor (`reviewedHint`) |
84
+ | any 5xx whose code did not opt in with `registerProblemMeta({ CODE: { publicCause: true } })` | `As of 2026-09-23`, not only an undeclared one — `X_DB_STATEMENT_FAILED` has a status row and served the Postgres message and the SQL. The framework opts in `X_DRAINING`, `X_OVERLOADED`, `X_FLIGHT_GATE_OVERLOADED` and `X_TIMEOUT`, whose cause is the instruction. The rest: |
84
85
  | a 5xx nobody declared a status for | the code, the request id and a `fix:`; never the exception's own text. `error-page.ts` locked the browser out of it, and the problem document handed the same string to an agent — a driver's DSN, the row Postgres rejected. The real text goes to the log and the error report. `dev: true` renders it in full |
85
86
  | a `security.csp.extend` key that is not a CSP token, or a source carrying `;`, `,` or a space | `X_CSP_DIRECTIVE_INVALID` at `defineHttpConfig` — `{ 'x; script-src *': [] }` is a second directive nobody declared |
86
87
  | a repeated form field | a LIST, exactly as a repeated query parameter is. One collector for query, urlencoded and multipart; `Object.fromEntries` kept the last value, so a checkbox group reached the schema as one string |
@@ -97,7 +98,11 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
97
98
  | 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 EXACT listing in `cors.origins` — anything else is refused before the body is read. `origins: ['*']` lists nobody here: `'*'` is a value for the response header, not a per-origin allowance |
98
99
  | 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 |
99
100
  | 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 |
100
- | 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 |
101
+ | a request while the process is draining | SERVED, with `connection: close` so the client's next request lands on another pod, `As of 2026-09-23` — refusing it failed 598 of 7,690 requests across one helm upgrade on kind, every one on a kept-alive connection inside the readiness grace. Only a STOPPED process (resources closed) answers `X_DRAINING` (503) + `retry-after` |
102
+ | SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `createServer({ …, drain: appConfig.drain })` so `app.config.ts`'s value is the one applied; omitted, core's default holds and a `configureLifecycle({ readinessGraceMs })` stands |
103
+ | `?locale=es` | the locale source that outranks the cookie and the header (`resolveLocale`'s order), `As of 2026-09-23` — documented in `wiki/I18n.md` and never read before |
104
+ | a 4xx | logged at `warn` (401, 403, 429) or `info` (every other 4xx); only a 5xx is an `error` line |
105
+ | an `x-forwarded-client-cert` value in quotes | unescaped ONCE, after the pairs are split — `Subject="O=Acme; Inc,CN=svc-one"` is that whole subject, not `O=Acme` |
101
106
  | a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
102
107
 
103
108
  `handle()` resolves to a Response, always — a stage that throws after the handler, or while
@@ -354,6 +359,9 @@ import { registerErrorStatus, registerProblemMeta } from '@ultimat3/http';
354
359
 
355
360
  registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
356
361
  registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
362
+ // A 5xx whose cause is written for the caller, and may be shown in production:
363
+ registerProblemMeta({ X_BILLING_DOWN: { publicCause: true } });
364
+ // Both at once: { keys: ['sessionId'], publicCause: true }
357
365
  ```
358
366
 
359
367
  The document then carries `meta: { sessionId, title, state }` — the declared keys that are set,
@@ -366,6 +374,15 @@ as is `issues`, which has its own top-level home. `@ultimat3/action`'s typed cli
366
374
  member back on the rebuilt error's `meta`, so an island reads `error.meta.sessionId` where it
367
375
  used to run a regex over `cause`.
368
376
 
377
+ ### Error classes
378
+
379
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
380
+ a job boundary the class is gone and the `code` is what survives — match on that.
381
+
382
+ | Class | Code | Declared in |
383
+ |---|---|---|
384
+ | `HttpError` | any `HttpErrorCode` — `HTTP_ERROR_CODES` | `src/errors.ts` |
385
+
369
386
  ## Boundaries
370
387
 
371
388
  Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "21.0.0",
3
+ "version": "22.1.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": "21.0.0",
35
- "@ultimat3/i18n": "21.0.0",
36
- "@ultimat3/schema": "21.0.0",
37
- "@ultimat3/time": "21.0.0"
34
+ "@ultimat3/core": "22.1.0",
35
+ "@ultimat3/i18n": "22.1.0",
36
+ "@ultimat3/schema": "22.1.0",
37
+ "@ultimat3/time": "22.1.0"
38
38
  }
39
39
  }
@@ -40,3 +40,12 @@ export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint
40
40
  if (!isAnonymous(actor)) return PRIVATE_CACHE;
41
41
  return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
42
42
  };
43
+
44
+ /**
45
+ * A DECLARED hint — `meta.cache` or `ctx.cache` — reviewed against the actor, as a handler's own
46
+ * header is. `mode: 'public'` on a route answered `public, s-maxage=3600` over a body carrying the
47
+ * signed-in actor's id, so a CDN served one user's document to every later visitor. `immutable`
48
+ * is exempt for the reason `offersSharedCache` gives: its body is a function of the URL alone.
49
+ */
50
+ export const reviewedHint = (hint: CacheHint, actor: Actor): CacheHint =>
51
+ hint.mode === 'public' && !isAnonymous(actor) ? PRIVATE_CACHE : hint;
package/src/csrf.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  // went through. `setRedirect` exists so those form posts work without JS, which makes them a
6
6
  // first-class surface here rather than a legacy one.
7
7
 
8
+ import { classifyAddress, proveSameOrigin } from '@ultimat3/core';
8
9
  import type { CorsConfig } from './cors';
9
10
  import { originListed } from './cors';
11
+ import { HttpError } from './errors';
10
12
 
11
13
  export type CsrfMode = 'origin' | 'off';
12
14
 
@@ -24,9 +26,6 @@ export const DEFAULT_CSRF: CsrfConfig = { mode: 'origin' };
24
26
  /** Methods with no side effects, per RFC 9110. A CSRF check on these is a check on nothing. */
25
27
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
26
28
 
27
- /** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
28
- const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
29
-
30
29
  export interface CsrfCheckInput {
31
30
  readonly method: string;
32
31
  /** The origin this app was reached on — scheme from `ctx.https`, host from the request URL. */
@@ -58,31 +57,49 @@ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
58
57
  if (input.anonymous) return { ok: true };
59
58
  if (input.hasAuthorizationHeader) return { ok: true };
60
59
 
61
- const site = input.secFetchSite;
62
- if (site === 'same-origin' || site === 'none') return { ok: true };
63
- if (input.origin === input.selfOrigin) return { ok: true };
60
+ // The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
61
+ // come from this app?" on every surface an ambient credential reaches.
62
+ //
64
63
  // `originListed`, never `allowedOrigin`: that one answers the RESPONSE header, and for
65
64
  // `origins: ['*'], credentials: false` — the only wildcard `assertCorsConfig` admits — its
66
65
  // answer is `'*'`, which is not null and so read as "this origin is one we allow". A
67
66
  // credentialed cross-site POST from evil.test was therefore accepted by the check that exists
68
67
  // to refuse exactly it. An exact match is the only allowance a write may be built on.
69
- if (originListed(input.cors, input.origin)) return { ok: true };
70
- // Only the four values a browser can send are quoted back. Anything else is a client that
71
- // wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
72
- // store and the response body — the same defect the error-map stage's log line had.
73
- if (site !== null) {
74
- const known = KNOWN_SITES.has(site) ? site : 'a value no browser sends';
75
- return { ok: false, reason: `the request reported sec-fetch-site: ${known}` };
76
- }
77
- return {
78
- ok: false,
79
- reason:
80
- input.origin === null
81
- ? 'the request carried neither sec-fetch-site nor origin, so it cannot be shown to be same-origin'
82
- : 'the origin it declares is not this app and is not listed in http.cors.origins',
83
- };
68
+ return proveSameOrigin({
69
+ selfOrigins: [input.selfOrigin],
70
+ origin: input.origin,
71
+ secFetchSite: input.secFetchSite,
72
+ listed: (origin) => originListed(input.cors, origin),
73
+ listName: 'http.cors.origins',
74
+ });
84
75
  };
85
76
 
86
77
  /** The origin a browser compares against — the PUBLIC one, so a TLS-terminating proxy agrees. */
87
78
  export const selfOrigin = (url: URL, https: boolean): string =>
88
79
  `${https ? 'https' : 'http'}://${url.host}`;
80
+
81
+ /**
82
+ * A write that arrived with the browser's ambient credential and could not be shown to come from
83
+ * this app. Never a 401: the caller IS signed in, which is precisely the problem.
84
+ *
85
+ * From a LOOPBACK `ip` the caller is `curl` against `x dev`: it sends neither
86
+ * header, and the remedies below name a token or a config change dev does not need. The header a
87
+ * browser would send is the whole repair there, and it is safe to hand out: CSRF defends a BROWSER
88
+ * from a hostile page, and a client that can set headers is not one a page can drive.
89
+ */
90
+ export const csrfBlocked = (
91
+ pathname: string,
92
+ reason: string,
93
+ ip: string | null = null,
94
+ ): HttpError =>
95
+ ip !== null && classifyAddress(ip) === 'loopback'
96
+ ? new HttpError({
97
+ code: 'X_CSRF_BLOCKED',
98
+ cause: `${pathname} refused a credentialed write: ${reason}`,
99
+ fix: "curl -H 'sec-fetch-site: same-origin' -X POST http://localhost:3000/the-path-above # a local client proves same-origin the way a browser does; the session cookie still decides who is calling",
100
+ })
101
+ : new HttpError({
102
+ code: 'X_CSRF_BLOCKED',
103
+ cause: `${pathname} refused a credentialed write: ${reason}`,
104
+ fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
105
+ });
@@ -11,9 +11,9 @@ import {
11
11
  stringField,
12
12
  } from '@ultimat3/core';
13
13
  import type { ValidationIssue } from '@ultimat3/schema';
14
- import { declaredStatusFor, statusFor } from './error-map';
14
+ import { declaredStatusFor, statusFor } from './error-status';
15
15
  import { HTTP_ERROR_TITLES } from './errors';
16
- import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
16
+ import { hasPublicCause, type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
17
17
 
18
18
  /** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
19
19
  export interface ErrorFacts {
@@ -300,11 +300,17 @@ export const toProblem = (
300
300
  ): ProblemDocument => {
301
301
  const facts = factsOf(error);
302
302
  const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
303
+ // Wider than `opaque`: EVERY 5xx cause is the server's own business unless its code declared it
304
+ // public (`hasPublicCause`). A status row made `X_DB_STATEMENT_FAILED` "classified", so its cause
305
+ // — the Postgres message and the statement — was served in production 500 bodies. The title
306
+ // stays: a registered title is framework prose, never the server's words.
307
+ const hidden =
308
+ opaque || (meta.dev !== true && facts.status >= 500 && !hasPublicCause(facts.code));
303
309
  // Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list
304
310
  // on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to
305
311
  // withhold — it names the fields and the expectations of something the caller was never meant to
306
312
  // see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
307
- const issues = opaque ? undefined : issuesOf(error);
313
+ const issues = hidden ? undefined : issuesOf(error);
308
314
  // The same condition, for the same reason: a code nobody classified cannot have declared any
309
315
  // key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
310
316
  const carried = opaque ? undefined : metaOf(error, facts.code);
@@ -312,10 +318,10 @@ export const toProblem = (
312
318
  type: problemTypeFor(facts.code),
313
319
  title: opaque ? INTERNAL_TITLE : facts.title,
314
320
  status: facts.status,
315
- detail: opaque ? INTERNAL_CAUSE : facts.cause,
321
+ detail: hidden ? INTERNAL_CAUSE : facts.cause,
316
322
  instance: meta.instance,
317
323
  code: facts.code,
318
- cause: opaque ? INTERNAL_CAUSE : facts.cause,
324
+ cause: hidden ? INTERNAL_CAUSE : facts.cause,
319
325
  fix: facts.fix,
320
326
  docs: facts.docs,
321
327
  requestId: meta.requestId,
@@ -0,0 +1,12 @@
1
+ // Single responsibility: the log level of the one line the `error-map` stage writes per failed
2
+ // request. Every 4xx was logged at `error`, so an alert keyed on error lines paged for a typo'd URL.
3
+
4
+ /**
5
+ * `error` for 5xx — the server failed. `warn` for 401/403/429 — a refused credential, a refused
6
+ * permission or a limit, each worth seeing in bulk. `info` for every other 4xx — the caller's own
7
+ * mistake, which the problem document already explained to them.
8
+ */
9
+ export const errorLogLevel = (status: number): 'error' | 'warn' | 'info' => {
10
+ if (status >= 500) return 'error';
11
+ return status === 401 || status === 403 || status === 429 ? 'warn' : 'info';
12
+ };
package/src/error-map.ts CHANGED
@@ -1,8 +1,8 @@
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
- // Rendering a throwable for a reader is `error-facts.ts`; this file answers only the status.
5
- import { errorStatusInvalid } from './errors';
4
+ // Rendering a throwable for a reader is `error-facts.ts`; reading this table, and the app's own
5
+ // half of it, is `error-status.ts`. This file is the table.
6
6
 
7
7
  /**
8
8
  * code -> status. Codes owned by other packages are listed here on purpose: HTTP
@@ -215,6 +215,12 @@ export const ERROR_STATUS = {
215
215
  // 500, and it should page: a queue holding rows this build cannot read is an operator's
216
216
  // problem, and nothing the caller sent is wrong.
217
217
  X_JOB_ROW_STATUS_UNKNOWN: 500,
218
+ // A requeue of a job that is still live — the admin panel's retry, `x jobs retry` over HTTP. The
219
+ // job's STATE is wrong, not the server: 409, like `X_STORAGE_QUARANTINED`.
220
+ X_JOB_NOT_REQUEUEABLE: 409,
221
+ // Thrown by `job()` at declaration, while modules load; no request is answered with it. The row
222
+ // exists for `X_ACTION_JOB_UNBRIDGED`'s reason: the table is closed, and it is a 500 anyway.
223
+ X_JOB_DECLARATION_INVALID: 500,
218
224
  // Thrown by `registerJobs()` while the app's modules load, so no request is ever answered with
219
225
  // it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
220
226
  // closed one, and a code with no row is a 500 anyway.
@@ -277,6 +283,9 @@ export const ERROR_STATUS = {
277
283
  // paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
278
284
  // the client sends changes the answer, and the report to the on-call monitor is the point.
279
285
  X_QUERY_NOT_PAGEABLE: 500,
286
+ // A read filtering or sorting on a column its loader never selected: the fix is the loader's
287
+ // `select`, nothing the caller sends changes it — the same server-side decision as the row above.
288
+ X_QUERY_COLUMN_UNSELECTED: 500,
280
289
  // @ultimat3/i18n — a well-formed tag outside the set this app ships, asserted on a value the
281
290
  // caller supplied (`assertSupportedLocale`). 400 rather than 406: the http `locale` stage
282
291
  // negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
@@ -320,6 +329,9 @@ export const ERROR_STATUS = {
320
329
  // refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
321
330
  // can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
322
331
  X_STORAGE_QUARANTINED: 409,
332
+ // 409 for the same reason: `promoteAttachment` on a key that is not a pending upload — promoted
333
+ // twice, or never uploaded — is a state the caller can see and fix, not a server fault.
334
+ X_STORAGE_NOT_PENDING: 409,
323
335
  // 404, deliberately NOT 403: the org check fires before anything is read, so answering
324
336
  // "forbidden" would confirm that a key exists to the one caller who must not learn it.
325
337
  X_STORAGE_ORG_MISMATCH: 404,
@@ -411,6 +423,13 @@ export const ERROR_STATUS = {
411
423
  X_LOCAL_STORE_UNAVAILABLE: 500,
412
424
  X_MUTATOR_CLOCK_MISSING: 500,
413
425
  X_REALTIME_UNINSTALLED: 500,
426
+ // Boot-time: a realtime config and its environment that disagree refuse before any request.
427
+ X_REALTIME_TOPOLOGY: 500,
428
+ // The replicator's own Postgres connection failed TLS. A role that never answers a request, so the
429
+ // row keeps the table closed at 500, beside `X_REALTIME_TOPOLOGY`.
430
+ X_REPLICATION_TLS: 500,
431
+ // A browser page on a foreign origin asked for a socket — refused, not unauthenticated: 403.
432
+ X_SOCKET_ORIGIN_REFUSED: 403,
414
433
  X_RECORD_KEY_MISSING: 500,
415
434
  X_RECORD_REJECTED: 500,
416
435
  X_SYNC_UNCONFIGURED: 500,
@@ -420,80 +439,3 @@ export const ERROR_STATUS = {
420
439
  // has to be a compile error rather than an `undefined` a test then asserts `toBeNumber()` on.
421
440
  // Read it by a code the framework did not mint through `statusFor`, never by index.
422
441
  } satisfies Readonly<Record<string, number>>;
423
-
424
- export const DEFAULT_STATUS = 500;
425
-
426
- /**
427
- * The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
428
- *
429
- * `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
430
- * object literal, so it holds every name on `Object.prototype`: an app throwing
431
- * `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
432
- * `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
433
- * one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
434
- * `scripts/error-map.ts` reads the same table this way already.
435
- */
436
- const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
437
-
438
- const frameworkStatus = (code: string): number | undefined =>
439
- Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
440
-
441
- /**
442
- * Statuses for codes the APP owns. The table above is closed — it has to be, it is the
443
- * framework's own contract — and every code outside it fell to 500, so a wrong password was an
444
- * incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
445
- * whoever was on call. This is the app's half of the same table, kept separate so a registration
446
- * can never move `X_FORBIDDEN` off 403.
447
- */
448
- const APP_ERROR_STATUS = new Map<string, number>();
449
-
450
- /**
451
- * Declare the status for the codes this app throws. Call it once at boot, beside the module
452
- * that declares the codes — importing that module IS the registration, the convention
453
- * `registerActions` and `registerErrorCodes` already use.
454
- *
455
- * ```ts
456
- * registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
457
- * ```
458
- */
459
- export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
460
- for (const [code, status] of Object.entries(statuses)) {
461
- if (!Number.isInteger(status) || status < 100 || status > 599) {
462
- throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
463
- }
464
- // The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
465
- // to 200 would be an app whose 401 contract every client already depends on, changed.
466
- // Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
467
- // own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
468
- // framework already maps it to function toString() { [native code] }`.
469
- const framework = frameworkStatus(code);
470
- if (framework !== undefined) {
471
- throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
472
- }
473
- const existing = APP_ERROR_STATUS.get(code);
474
- if (existing !== undefined && existing !== status) {
475
- throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
476
- }
477
- APP_ERROR_STATUS.set(code, status);
478
- }
479
- };
480
-
481
- /** Test seam. Production registers once at boot and never unregisters. */
482
- export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
483
-
484
- /**
485
- * The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
486
- * nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
487
- * "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
488
- * reads to decide whether a 5xx may carry the throwable's own words back to the caller.
489
- *
490
- * Framework table first: `registerErrorStatus` already refuses those codes, so the order is
491
- * belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
492
- * even if a future caller reaches the map some other way.
493
- * `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
494
- * prefer one for anything keyed by a value a caller chose.
495
- */
496
- export const declaredStatusFor = (code: string): number | undefined =>
497
- frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
498
-
499
- export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
package/src/error-page.ts CHANGED
@@ -124,9 +124,17 @@ const hrefFor = (copy: ErrorPageCopy, input: ErrorPageInput): string => {
124
124
  return input.signInPath;
125
125
  // A retry has to be the page the visitor was on; with no request behind the page there is no
126
126
  // such address, so it degrades to the one link that is always right.
127
- return (copy.action === 'retry' ? input.path : undefined) ?? '/';
127
+ return copy.action === 'retry' && input.path !== undefined ? sameOriginPath(input.path) : '/';
128
128
  };
129
129
 
130
+ /**
131
+ * The path as a link that cannot leave this origin. A request for `//evil.com/x` has that
132
+ * pathname, and `href="//evil.com/x"` is protocol-relative — the retry button on the 503 served
133
+ * while draining sent the visitor to another host. A browser reads `\` as `/` in an http href,
134
+ * so the leading run of either collapses to one `/`.
135
+ */
136
+ const sameOriginPath = (path: string): string => path.replace(/^[/\\]*/, '/');
137
+
130
138
  const link = (href: string, label: string): string =>
131
139
  `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
132
140
 
@@ -0,0 +1,82 @@
1
+ // Reading the status table: the framework's row for a code through `Object.hasOwn`, the app's
2
+ // own codes beside it (`registerErrorStatus`), and the answer a response gets. The table itself is
3
+ // `error-map.ts`; split out when the table outgrew the file that also read it.
4
+ import { ERROR_STATUS } from './error-map';
5
+ import { errorStatusInvalid } from './errors';
6
+
7
+ export const DEFAULT_STATUS = 500;
8
+
9
+ /**
10
+ * The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
11
+ *
12
+ * `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
13
+ * object literal, so it holds every name on `Object.prototype`: an app throwing
14
+ * `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
15
+ * `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
16
+ * one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
17
+ * `scripts/error-map.ts` reads the same table this way already.
18
+ */
19
+ const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
20
+
21
+ const frameworkStatus = (code: string): number | undefined =>
22
+ Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
23
+
24
+ /**
25
+ * Statuses for codes the APP owns. The table above is closed — it has to be, it is the
26
+ * framework's own contract — and every code outside it fell to 500, so a wrong password was an
27
+ * incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
28
+ * whoever was on call. This is the app's half of the same table, kept separate so a registration
29
+ * can never move `X_FORBIDDEN` off 403.
30
+ */
31
+ const APP_ERROR_STATUS = new Map<string, number>();
32
+
33
+ /**
34
+ * Declare the status for the codes this app throws. Call it once at boot, beside the module
35
+ * that declares the codes — importing that module IS the registration, the convention
36
+ * `registerActions` and `registerErrorCodes` already use.
37
+ *
38
+ * ```ts
39
+ * registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
40
+ * ```
41
+ */
42
+ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
43
+ for (const [code, status] of Object.entries(statuses)) {
44
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
45
+ throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
46
+ }
47
+ // The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
48
+ // to 200 would be an app whose 401 contract every client already depends on, changed.
49
+ // Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
50
+ // own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
51
+ // framework already maps it to function toString() { [native code] }`.
52
+ const framework = frameworkStatus(code);
53
+ if (framework !== undefined) {
54
+ throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
55
+ }
56
+ const existing = APP_ERROR_STATUS.get(code);
57
+ if (existing !== undefined && existing !== status) {
58
+ throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
59
+ }
60
+ APP_ERROR_STATUS.set(code, status);
61
+ }
62
+ };
63
+
64
+ /** Test seam. Production registers once at boot and never unregisters. */
65
+ export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
66
+
67
+ /**
68
+ * The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
69
+ * nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
70
+ * "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
71
+ * reads to decide whether a 5xx may carry the throwable's own words back to the caller.
72
+ *
73
+ * Framework table first: `registerErrorStatus` already refuses those codes, so the order is
74
+ * belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
75
+ * even if a future caller reaches the map some other way.
76
+ * `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
77
+ * prefer one for anything keyed by a value a caller chose.
78
+ */
79
+ export const declaredStatusFor = (code: string): number | undefined =>
80
+ frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
81
+
82
+ export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
package/src/errors.ts CHANGED
@@ -440,17 +440,6 @@ export const overloaded = (inflight: number, ceiling: number): HttpError =>
440
440
  fix: 'retry after the Retry-After header; to serve more at once call configureHttp({ maxInflight: 2000 }) at module scope in a file under apps/*/, and add replicas to match',
441
441
  });
442
442
 
443
- /**
444
- * A write that arrived with the browser's ambient credential and could not be shown to come from
445
- * this app. Never a 401: the caller IS signed in, which is precisely the problem.
446
- */
447
- export const csrfBlocked = (pathname: string, reason: string): HttpError =>
448
- new HttpError({
449
- code: 'X_CSRF_BLOCKED',
450
- cause: `${pathname} refused a credentialed write: ${reason}`,
451
- fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
452
- });
453
-
454
443
  /**
455
444
  * The request ran past its deadline. `X_TIMEOUT` is borrowed (see `HTTP_BORROWED_ERROR_CODES`)
456
445
  * and already maps to 504. The abort fires first for cooperative code; this is what the socket
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
16
16
  export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
17
17
  export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
18
18
  export type { HttpConfig, HttpConfigInput } from './config';
19
- export { defineHttpConfig, MAX_PROXY_HOPS, stripBasePath } from './config';
19
+ export { defineHttpConfig, MAX_PROXY_HOPS } from './config';
20
20
  export type { ActorView, RequestContext, RequestContextInit } from './context';
21
21
  export {
22
22
  actorView,
@@ -29,11 +29,10 @@ export {
29
29
  useRequestHeaders,
30
30
  } from './context';
31
31
  export type { InboundCorrelation } from './correlation';
32
- export { readCorrelation } from './correlation';
33
32
  export type { CorsConfig } from './cors';
34
33
  export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
35
34
  export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
36
- export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
35
+ export { checkCsrf, csrfBlocked } from './csrf';
37
36
  export type { Deadline } from './deadline';
38
37
  export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
39
38
  export type { ErrorFacts, ProblemDocument } from './error-facts';
@@ -44,13 +43,7 @@ export {
44
43
  retryAfterOf,
45
44
  toProblem,
46
45
  } from './error-facts';
47
- export {
48
- DEFAULT_STATUS,
49
- ERROR_STATUS,
50
- registerErrorStatus,
51
- resetErrorStatus,
52
- statusFor,
53
- } from './error-map';
46
+ export { ERROR_STATUS } from './error-map';
54
47
  export type {
55
48
  ErrorPageAction,
56
49
  ErrorPageCopy,
@@ -64,11 +57,16 @@ export {
64
57
  renderErrorPage,
65
58
  resolveErrorPageCopy,
66
59
  } from './error-page';
60
+ export {
61
+ DEFAULT_STATUS,
62
+ registerErrorStatus,
63
+ resetErrorStatus,
64
+ statusFor,
65
+ } from './error-status';
67
66
  export type { HttpErrorCode } from './errors';
68
67
  export {
69
68
  bodyInvalid,
70
69
  buildSkew,
71
- csrfBlocked,
72
70
  draining,
73
71
  errorStatusInvalid,
74
72
  finalizeFailed,
@@ -94,29 +92,24 @@ export {
94
92
  export type { ForwardedInput, ForwardedSplit } from './forwarded';
95
93
  export {
96
94
  clientAddress,
97
- clientUsedHttps,
98
- FORWARDED_CLIENT_CERT,
99
- FORWARDED_FOR,
100
- FORWARDED_PROTO,
101
95
  forwardedElement,
102
- forwardedValue,
103
96
  } from './forwarded';
104
97
  export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
105
98
  export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
106
- export { acceptsHtml, escapeHtml } from './html-render';
99
+ export { escapeHtml } from './html-render';
107
100
  export type { LocaleConfig, TimeZoneConfig } from './locale';
108
- export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale';
101
+ export { DEFAULT_LOCALE_CONFIG, readCookie } from './locale';
109
102
  export type { Middleware } from './middleware';
110
103
  export { compose } from './middleware';
111
104
  export type { OverlayMeta, OverlayNotice } from './overlay';
112
- export { overlayResponse, renderOverlay, wantsOverlay } from './overlay';
105
+ export { overlayResponse, wantsOverlay } from './overlay';
113
106
  export { OVERLAY_STYLE } from './overlay-style';
114
107
  export type { PeerIdentity } from './peer-identity';
115
108
  export { peerIdentity } from './peer-identity';
116
109
  export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
117
110
  export { createPipeline, PIPELINE_STAGES } from './pipeline';
118
- export type { ProblemMeta, ProblemMetaValue } from './problem-meta';
119
- export { MAX_PROBLEM_META_BYTES, registerProblemMeta, resetProblemMeta } from './problem-meta';
111
+ export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
112
+ export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
120
113
  export type {
121
114
  Bucket,
122
115
  MemoryRateLimitStore,
@@ -138,7 +131,6 @@ export {
138
131
  rateLimitDecision,
139
132
  rateLimitSpends,
140
133
  resolveRateLimitConfig,
141
- TENANT_SCOPE,
142
134
  toBucket,
143
135
  } from './rate-limit';
144
136
  export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
@@ -159,10 +151,7 @@ export type {
159
151
  } from './rate-limit-postgres';
160
152
  export {
161
153
  postgresRateLimitStore,
162
- SQL_RATE_LIMIT_PURGE,
163
- SQL_RATE_LIMIT_RESET,
164
154
  SQL_RATE_LIMIT_TABLE,
165
- SQL_RATE_LIMIT_TAKE,
166
155
  } from './rate-limit-postgres';
167
156
  export { setRedirect, takeRedirect } from './redirect';
168
157
  export type { QueryValues } from './request';
@@ -196,7 +185,6 @@ export {
196
185
  describeRoutes,
197
186
  HTTP_METHODS,
198
187
  matchRoute,
199
- normalizePath,
200
188
  } from './router';
201
189
  export type { SecurityConfig } from './security-headers';
202
190
  export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';