@ultimat3/http 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CLAUDE.md +75 -31
  2. package/README.md +89 -26
  3. package/package.json +9 -6
  4. package/src/app-config.ts +3 -1
  5. package/src/auth-redirect.ts +56 -17
  6. package/src/bearer-mount.ts +81 -26
  7. package/src/cache-policy.ts +10 -2
  8. package/src/config.ts +62 -32
  9. package/src/context.ts +11 -11
  10. package/src/correlation.ts +2 -2
  11. package/src/cors.ts +3 -2
  12. package/src/csrf.ts +17 -8
  13. package/src/deadline.ts +10 -8
  14. package/src/error-facts.ts +18 -2
  15. package/src/error-map-http.ts +94 -0
  16. package/src/error-map-tier-0.ts +62 -0
  17. package/src/error-map-tier-1.ts +73 -0
  18. package/src/error-map-tier-2.ts +120 -0
  19. package/src/error-map-tier-3.ts +100 -0
  20. package/src/error-map-tier-4.ts +116 -0
  21. package/src/error-map.ts +27 -474
  22. package/src/error-page.ts +3 -4
  23. package/src/error-titles.ts +93 -0
  24. package/src/errors.ts +46 -96
  25. package/src/forwarded.ts +36 -10
  26. package/src/health-disclosure.ts +66 -0
  27. package/src/hooks.ts +6 -5
  28. package/src/html-render.ts +3 -17
  29. package/src/index.ts +36 -27
  30. package/src/locale-prefix.ts +5 -1
  31. package/src/locale.ts +0 -27
  32. package/src/navigation.ts +26 -1
  33. package/src/overlay.ts +3 -1
  34. package/src/peer-identity.ts +10 -6
  35. package/src/pipeline.ts +32 -13
  36. package/src/preferences.ts +1 -1
  37. package/src/problem-meta.ts +7 -21
  38. package/src/rate-limit-buckets.ts +26 -2
  39. package/src/rate-limit-errors.ts +36 -10
  40. package/src/rate-limit-headers.ts +40 -0
  41. package/src/rate-limit-installed.ts +79 -0
  42. package/src/rate-limit-peek.ts +32 -0
  43. package/src/rate-limit-postgres.ts +30 -21
  44. package/src/rate-limit-stage.ts +132 -0
  45. package/src/rate-limit.ts +40 -15
  46. package/src/request-facts.ts +44 -0
  47. package/src/request.ts +12 -5
  48. package/src/response.ts +10 -2
  49. package/src/route-cache.ts +1 -1
  50. package/src/router.ts +36 -8
  51. package/src/server.ts +101 -30
  52. package/src/set-cookie.ts +60 -0
  53. package/src/stages.ts +50 -54
  54. package/src/validate.ts +2 -2
package/src/error-page.ts CHANGED
@@ -5,10 +5,9 @@
5
5
  //
6
6
  // An app overrides it with a file per status; `ServerHooks.errorPage` is the seam that reads one,
7
7
  // because this package cannot see a disk.
8
- import { singleLine } from '@ultimat3/core';
8
+ import { escapeHtml, singleLine } from '@ultimat3/core';
9
9
  import type { InterpolationVars } from '@ultimat3/i18n';
10
10
  import { t } from '@ultimat3/i18n';
11
- import { escapeHtml } from './html-render';
12
11
  import { OVERLAY_STYLE } from './overlay-style';
13
12
  import { html } from './response';
14
13
 
@@ -166,11 +165,11 @@ export function renderErrorPage(input: ErrorPageInput): string {
166
165
  <p class="lede">${escapeHtml(t(`errors.${copy.group}.body`, copy.vars))}</p>
167
166
  <p>${link(hrefFor(copy, input), t(`errors.${copy.group}.action`))}</p>
168
167
  <dl>
169
- <dt>code</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${
168
+ <dt>${escapeHtml(t('errors.page.code'))}</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${
170
169
  input.requestId === undefined
171
170
  ? ''
172
171
  : `
173
- <dt>request</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>`
172
+ <dt>${escapeHtml(t('errors.page.request'))}</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>`
174
173
  }
175
174
  </dl>
176
175
  </section>
@@ -0,0 +1,93 @@
1
+ // @ultimat3/http's owned codes, their TITLES and the two retry classes, registered at import — the
2
+ // one module `sideEffects` names, imported bare by the barrel and by `errors.ts`. Its readers are
3
+ // in the SERVER realm (`x errors list`, a problem document, a job's retry), which reaches the
4
+ // barrel. A browser never needs it: core's decoders take an unregistered code's title from the
5
+ // problem body and a 429's `retry-after` from its `Retry-After` header. Until the package declared
6
+ // `sideEffects`, all of http — router, limiter, error maps, 11.5 kB — rode into every action island.
7
+
8
+ import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core';
9
+
10
+ /** Codes this package declares and owns. */
11
+ export const HTTP_OWNED_ERROR_CODES = [
12
+ 'X_ROUTE_NOT_FOUND',
13
+ 'X_METHOD_NOT_ALLOWED',
14
+ 'X_PATH_INVALID',
15
+ 'X_BODY_INVALID',
16
+ 'X_RATE_LIMITED',
17
+ 'X_BUILD_SKEW',
18
+ 'X_ROUTE_CONFLICT',
19
+ 'X_SERVER_NOT_STARTED',
20
+ 'X_PIPELINE_NO_RESPONSE',
21
+ 'X_PIPELINE_FINALIZE_FAILED',
22
+ 'X_NO_REQUEST',
23
+ 'X_ERROR_STATUS_INVALID',
24
+ 'X_PROBLEM_META_INVALID',
25
+ 'X_CORS_CONFIG_INVALID',
26
+ 'X_CSP_DIRECTIVE_INVALID',
27
+ 'X_RATE_LIMIT_NOT_SHARED',
28
+ 'X_RATE_LIMIT_BUCKET_CONFLICT',
29
+ 'X_RATE_LIMIT_BUCKET_UNBOUND',
30
+ 'X_RATE_LIMIT_SCOPE_UNSET',
31
+ 'X_RATE_LIMIT_INVALID',
32
+ 'X_RATE_LIMIT_STORE_UNAVAILABLE',
33
+ 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
34
+ 'X_TRUST_PROXY_UNSET',
35
+ 'X_OVERLOADED',
36
+ 'X_CSRF_BLOCKED',
37
+ 'X_WEBHOOK_SIGNATURE_INVALID',
38
+ 'X_WEBHOOK_SIGNATURE_STALE',
39
+ 'X_BEARER_MOUNT_INVALID',
40
+ ] as const;
41
+
42
+ export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
43
+
44
+ /** Human title per owned code. Kept next to the codes so one edit updates every surface. */
45
+ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
46
+ X_ROUTE_NOT_FOUND: 'no route matches this request',
47
+ X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
48
+ X_PATH_INVALID: 'a path segment is not valid percent-encoding',
49
+ X_BODY_INVALID: 'request body failed its schema',
50
+ X_RATE_LIMITED: 'rate limit exhausted for this key',
51
+ X_BUILD_SKEW: 'client build id does not match the server build id',
52
+ X_ROUTE_CONFLICT: 'two routes claim the same path',
53
+ X_SERVER_NOT_STARTED: 'server handle used before start()',
54
+ X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
55
+ X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
56
+ X_NO_REQUEST: 'the inbound request is not in scope here',
57
+ X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
58
+ X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
59
+ X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
60
+ X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
61
+ X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
62
+ X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
63
+ X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
64
+ X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
65
+ X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
66
+ X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
67
+ X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
68
+ X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
69
+ X_OVERLOADED: 'in-flight requests are at the configured ceiling',
70
+ X_CSRF_BLOCKED: 'an unsafe request that did not prove same-origin',
71
+ X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
72
+ X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
73
+ X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
74
+ };
75
+
76
+ // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
77
+ // everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
78
+ // presence guard, a package that claimed one of these first would silently keep its own title.
79
+ registerErrorCodes(
80
+ Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
81
+ );
82
+
83
+ /**
84
+ * The two codes this package throws that a client is SUPPOSED to come back from, and both say
85
+ * when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
86
+ * right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
87
+ * a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
88
+ * `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
89
+ */
90
+ registerErrorRetry({
91
+ X_RATE_LIMITED: 'retry-after',
92
+ X_OVERLOADED: 'retry-after',
93
+ });
package/src/errors.ts CHANGED
@@ -1,46 +1,18 @@
1
1
  // The HTTP layer's stable error codes. Every throw in this package goes through a
2
2
  // factory here so a code, a cause and an exact fix always travel together — the
3
3
  // terminal, the dev overlay and `--json` all render the same three strings.
4
- import {
5
- deniedCallerFix,
6
- registerErrorCodes,
7
- registerErrorRetry,
8
- renderFixShellArg,
9
- renderThrowable,
10
- UltimateError,
11
- } from '@ultimat3/core';
12
-
13
- /** Codes this package declares and owns. */
14
- export const HTTP_OWNED_ERROR_CODES = [
15
- 'X_ROUTE_NOT_FOUND',
16
- 'X_METHOD_NOT_ALLOWED',
17
- 'X_PATH_INVALID',
18
- 'X_BODY_INVALID',
19
- 'X_RATE_LIMITED',
20
- 'X_BUILD_SKEW',
21
- 'X_ROUTE_CONFLICT',
22
- 'X_SERVER_NOT_STARTED',
23
- 'X_PIPELINE_NO_RESPONSE',
24
- 'X_PIPELINE_FINALIZE_FAILED',
25
- 'X_NO_REQUEST',
26
- 'X_ERROR_STATUS_INVALID',
27
- 'X_PROBLEM_META_INVALID',
28
- 'X_CORS_CONFIG_INVALID',
29
- 'X_CSP_DIRECTIVE_INVALID',
30
- 'X_RATE_LIMIT_NOT_SHARED',
31
- 'X_RATE_LIMIT_BUCKET_CONFLICT',
32
- 'X_RATE_LIMIT_BUCKET_UNBOUND',
33
- 'X_RATE_LIMIT_SCOPE_UNSET',
34
- 'X_RATE_LIMIT_INVALID',
35
- 'X_RATE_LIMIT_STORE_UNAVAILABLE',
36
- 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
37
- 'X_TRUST_PROXY_UNSET',
38
- 'X_OVERLOADED',
39
- 'X_CSRF_BLOCKED',
40
- 'X_WEBHOOK_SIGNATURE_INVALID',
41
- 'X_WEBHOOK_SIGNATURE_STALE',
42
- 'X_BEARER_MOUNT_INVALID',
43
- ] as const;
4
+ import { deniedCallerFix, renderFixShellArg, renderThrowable, UltimateError } from '@ultimat3/core';
5
+ // The owned code list, its titles and its retry classes are `error-titles.ts` — the one module
6
+ // `sideEffects` lists, imported bare here and from the barrel — so this module is side-effect-free
7
+ // and a chunk keeps only the classes it constructs. Re-exported: every name still reads from here.
8
+ import './error-titles';
9
+ import { HTTP_OWNED_ERROR_CODES } from './error-titles';
10
+
11
+ export {
12
+ HTTP_ERROR_TITLES,
13
+ HTTP_OWNED_ERROR_CODES,
14
+ type HttpOwnedErrorCode,
15
+ } from './error-titles';
44
16
 
45
17
  /**
46
18
  * Codes this package throws but does NOT own. `X_FORBIDDEN` belongs to `@ultimat3/policy` and
@@ -71,60 +43,8 @@ export const HTTP_BORROWED_ERROR_CODES = [
71
43
  /** Every code http can throw: the ones it owns plus the two it borrows. */
72
44
  export const HTTP_ERROR_CODES = [...HTTP_OWNED_ERROR_CODES, ...HTTP_BORROWED_ERROR_CODES] as const;
73
45
 
74
- export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
75
46
  export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
76
47
 
77
- /** Human title per owned code. Kept next to the codes so one edit updates every surface. */
78
- export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
79
- X_ROUTE_NOT_FOUND: 'no route matches this request',
80
- X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
81
- X_PATH_INVALID: 'a path segment is not valid percent-encoding',
82
- X_BODY_INVALID: 'request body failed its schema',
83
- X_RATE_LIMITED: 'rate limit exhausted for this key',
84
- X_BUILD_SKEW: 'client build id does not match the server build id',
85
- X_ROUTE_CONFLICT: 'two routes claim the same path',
86
- X_SERVER_NOT_STARTED: 'server handle used before start()',
87
- X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
88
- X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
89
- X_NO_REQUEST: 'the inbound request is not in scope here',
90
- X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
91
- X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
92
- X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
93
- X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
94
- X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
95
- X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
96
- X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
97
- X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
98
- X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
99
- X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
100
- X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
101
- X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
102
- X_OVERLOADED: 'in-flight requests are at the configured ceiling',
103
- X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
104
- X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
105
- X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
106
- X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
107
- };
108
-
109
- // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
110
- // everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
111
- // presence guard, a package that claimed one of these first would silently keep its own title.
112
- registerErrorCodes(
113
- Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
114
- );
115
-
116
- /**
117
- * The two codes this package throws that a client is SUPPOSED to come back from, and both say
118
- * when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
119
- * right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
120
- * a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
121
- * `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
122
- */
123
- registerErrorRetry({
124
- X_RATE_LIMITED: 'retry-after',
125
- X_OVERLOADED: 'retry-after',
126
- });
127
-
128
48
  // No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
129
49
  // `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
130
50
  // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
@@ -166,7 +86,7 @@ export const routeNotFound = (method: string, pathname: string): HttpError =>
166
86
  code: 'X_ROUTE_NOT_FOUND',
167
87
  // The value itself still travels, in the field that is read rather than run.
168
88
  cause: `no route registered for ${method} ${pathname}`,
169
- fix: `x routes list --json # then: x g route ${renderFixShellArg(pathname, '<the path the cause names>')}`,
89
+ fix: `x routes --json # then: x g route ${renderFixShellArg(pathname, '<the path the cause names>')}`,
170
90
  });
171
91
 
172
92
  export const methodNotAllowed = (
@@ -274,7 +194,7 @@ export const serverNotStarted = (member: string): HttpError =>
274
194
  new HttpError({
275
195
  code: 'X_SERVER_NOT_STARTED',
276
196
  cause: `${member} was read before start() bound a socket`,
277
- fix: 'call createServer({ ... }).start() before reading url()',
197
+ fix: 'call httpServer({ ... }).start() before reading url()',
278
198
  });
279
199
 
280
200
  export const pipelineNoResponse = (stage: string): HttpError =>
@@ -297,7 +217,7 @@ export const finalizeFailed = (stage: string, cause: unknown): HttpError =>
297
217
  // runs a `Proxy`'s `getPrototypeOf` trap and `.message` runs a getter, so both reads go
298
218
  // through core's total `renderThrowable` — the fast path was the last unguarded one here.
299
219
  cause: `the "${stage}" stage threw while finishing the response: ${renderThrowable(cause)}`,
300
- fix: 'return a Response built here — json(), text(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
220
+ fix: 'return a Response built here — jsonResponse(), textResponse(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
301
221
  });
302
222
 
303
223
  /**
@@ -361,7 +281,7 @@ export const routeConflict = (path: string, detail: string): HttpError =>
361
281
  new HttpError({
362
282
  code: 'X_ROUTE_CONFLICT',
363
283
  cause: `${path} conflicts with an already registered route: ${detail}`,
364
- fix: `x routes list --json # remove or rename one of the two routes at ${path}`,
284
+ fix: `x routes --json # remove or rename one of the two routes at ${path}`,
365
285
  });
366
286
 
367
287
  /**
@@ -379,6 +299,36 @@ export const trustProxyUnset = (): HttpError =>
379
299
  fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
380
300
  });
381
301
 
302
+ /**
303
+ * `http.drainTimeoutMs`, deleted in 25.0.0. It was a second knob for the budget `drain.deadlineMs`
304
+ * owns: applied after `lifecycleForRole` on the web role only, so the web role and every other
305
+ * role could drain on different budgets from one app config. Refused by key PRESENCE — a JS caller
306
+ * or a spread still handing it over gets the edit, never a silently ignored number.
307
+ */
308
+ export const drainTimeoutDeleted = (): HttpError =>
309
+ new HttpError({
310
+ code: 'X_CONFIG_INVALID',
311
+ cause:
312
+ 'http.drainTimeoutMs was deleted in 25.0.0: drain.deadlineMs is the one drain budget, for every role, and a second key for the web role alone is two answers to how long SIGTERM waits',
313
+ fix: 'drain: { deadlineMs: 25_000 } # in app.config.ts: drain.deadlineMs is the one budget every role, web included, drains on — and delete drainTimeoutMs from configureHttp({ … })',
314
+ meta: { option: 'drainTimeoutMs', replacement: 'drain.deadlineMs' },
315
+ });
316
+
317
+ /**
318
+ * `http.buildIdHeader`, deleted in 25.0.0. The pipeline read the client's build claim from it and
319
+ * stamped its own back under it, while the typed client, the service worker and `DEFAULT_CORS`
320
+ * all spelled core's `BUILD_ID_HEADER` — so a renamed header was a server listening for a header
321
+ * no client of it sent, and skew detection off in silence. Refused by key PRESENCE.
322
+ */
323
+ export const buildIdHeaderDeleted = (): HttpError =>
324
+ new HttpError({
325
+ code: 'X_CONFIG_INVALID',
326
+ cause:
327
+ 'http.buildIdHeader was deleted in 25.0.0: the build-id header is core BUILD_ID_HEADER (x-ultimate-build), which every client sends, and a renamed one is a header no client of this server sends',
328
+ fix: "import { BUILD_ID_HEADER } from '@ultimat3/core' # the one build-id header, not configurable — and delete buildIdHeader from configureHttp({ … })",
329
+ meta: { option: 'buildIdHeader', replacement: 'BUILD_ID_HEADER' },
330
+ });
331
+
382
332
  /**
383
333
  * A numeric knob that is not a count, refused where `app.config.ts` still names it.
384
334
  *
package/src/forwarded.ts CHANGED
@@ -43,10 +43,25 @@ export const forwardedElement = (
43
43
  return list[index];
44
44
  };
45
45
 
46
- /** The plain-list form: `x-forwarded-for`, `x-forwarded-proto`. */
46
+ /** The plain-list form: `x-forwarded-for`. */
47
47
  export const forwardedValue = (header: string | null, hops: number): string | undefined =>
48
48
  forwardedElement(header, hops);
49
49
 
50
+ /**
51
+ * `x-forwarded-proto`, which proxies OVERWRITE far more often than they append (nginx's `$scheme`,
52
+ * an ALB, Traefik): behind two hops it holds one value while `x-forwarded-for` holds two. A full
53
+ * chain is read exactly like an address. A SHORTER one is not a forgery here: an overwrite erased
54
+ * everything client-ward of it, so every entry left was written by a trusted hop, and the
55
+ * outermost of them — index 0 — is the one that saw the client's leg. A client value survives only
56
+ * when no trusted hop writes the header at all, the same exposure `hops: 1` has always had.
57
+ */
58
+ export const forwardedProto = (header: string | null, hops: number): string | undefined => {
59
+ if (header === null || hops < 1) return undefined;
60
+ const list = plainSplit(header, ',');
61
+ if (list.length === 0) return undefined;
62
+ return list[Math.max(0, list.length - hops)];
63
+ };
64
+
50
65
  /**
51
66
  * `1.2.3.4:5678` and `[::1]:443` are both legal in an `x-forwarded-for` entry; the port is the
52
67
  * proxy's bookkeeping and would make every connection its own rate-limit bucket.
@@ -71,21 +86,32 @@ export interface ForwardedInput {
71
86
  readonly urlProtocol: string;
72
87
  }
73
88
 
74
- /** The address the rate limiter keys on and the audit trail records. */
75
- export const clientAddress = (input: ForwardedInput): string | null => {
76
- const forwarded = forwardedValue(input.headers.get(FORWARDED_FOR), input.config.trustedProxyHops);
77
- if (forwarded === undefined) return input.socketAddress;
89
+ /**
90
+ * The caller a DECLARED proxy named, or `undefined` when none did — an undeclared deployment, no
91
+ * header, a chain shorter than the declaration. Split out for the one reader that must tell "the
92
+ * proxy named nobody" from "the socket is the caller" (`health-disclosure.ts`).
93
+ */
94
+ export const forwardedClientAddress = (
95
+ headers: Headers,
96
+ config: HttpConfig,
97
+ ): string | undefined => {
98
+ const forwarded = forwardedValue(headers.get(FORWARDED_FOR), config.trustedProxyHops);
99
+ if (forwarded === undefined) return undefined;
78
100
  const address = withoutPort(forwarded);
79
- return address.length > 0 ? address : input.socketAddress;
101
+ return address.length > 0 ? address : undefined;
80
102
  };
81
103
 
104
+ /** The address the rate limiter keys on and the audit trail records. */
105
+ export const clientAddress = (input: ForwardedInput): string | null =>
106
+ forwardedClientAddress(input.headers, input.config) ?? input.socketAddress;
107
+
82
108
  /**
83
- * Whether the CLIENT's leg of the connection was TLS. Read at the same hop index as the address,
84
- * because `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether
85
- * a two-year `includeSubDomains` HSTS policy goes out.
109
+ * Whether the CLIENT's leg of the connection was TLS. Hop-indexed like the address, because
110
+ * `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether a
111
+ * two-year `includeSubDomains` HSTS policy goes out. `forwardedProto` says why a short list counts.
86
112
  */
87
113
  export const clientUsedHttps = (input: ForwardedInput): boolean => {
88
- const forwarded = forwardedValue(
114
+ const forwarded = forwardedProto(
89
115
  input.headers.get(FORWARDED_PROTO),
90
116
  input.config.trustedProxyHops,
91
117
  );
@@ -0,0 +1,66 @@
1
+ // Who `/healthz` and `/readyz` tell the detail to on the WEB role: the `healthDetailPeers` config
2
+ // key, its screen, and the trusted-proxy half. The body rule and the peer-list match are core's
3
+ // (`health-disclosure.ts` there), shared with the sync role's own listener — one rule, two callers.
4
+
5
+ import {
6
+ type AddressClass,
7
+ classifyAddress,
8
+ healthPeerListed,
9
+ renderCauseValue,
10
+ } from '@ultimat3/core';
11
+ import type { HttpConfig } from './config';
12
+ import { HttpError } from './errors';
13
+ import { forwardedClientAddress } from './forwarded';
14
+
15
+ /** Every class an entry may name. A `Record` so a class core adds is a build error here. */
16
+ const ADDRESS_CLASSES = Object.freeze<Record<AddressClass, true>>({
17
+ loopback: true,
18
+ private: true,
19
+ 'link-local': true,
20
+ ula: true,
21
+ cgnat: true,
22
+ unspecified: true,
23
+ reserved: true,
24
+ public: true,
25
+ });
26
+
27
+ const isAddressClass = (entry: string): entry is AddressClass =>
28
+ Object.hasOwn(ADDRESS_CLASSES, entry);
29
+
30
+ /** Beside its one caller: `errors.ts` is at the 500-line ceiling (`server.ts` does the same). */
31
+ const healthDetailPeersInvalid = (value: unknown): HttpError =>
32
+ new HttpError({
33
+ code: 'X_CONFIG_INVALID',
34
+ cause: `http.healthDetailPeers holds ${renderCauseValue(value)}; it must be a list whose every entry is an address class (${Object.keys(ADDRESS_CLASSES).join(', ')}) or one IP address literal — a range, a hostname or a misspelt class would match nobody, and the health endpoints would silently withhold from the peer it was written for`,
35
+ fix: "configureHttp({ healthDetailPeers: ['loopback', 'private'] })",
36
+ });
37
+
38
+ /** The declared list, or the refusal naming the entry no address can ever match. */
39
+ export const assertHealthDetailPeers = (peers: readonly string[]): readonly string[] => {
40
+ const declared: unknown = peers;
41
+ if (!Array.isArray(declared)) throw healthDetailPeersInvalid(declared);
42
+ for (const entry of declared as readonly unknown[]) {
43
+ if (typeof entry !== 'string') throw healthDetailPeersInvalid(entry);
44
+ if (!isAddressClass(entry) && classifyAddress(entry) === undefined) {
45
+ throw healthDetailPeersInvalid(entry);
46
+ }
47
+ }
48
+ return [...(declared as readonly string[])];
49
+ };
50
+
51
+ /**
52
+ * Whether this caller is told the detail. The SOCKET must be listed — and, when a declared proxy
53
+ * named a caller, that caller too: a proxy on this box makes every socket loopback, and a direct
54
+ * caller forging `x-forwarded-for` still arrives on a socket nobody listed. Undeclared, the
55
+ * header is not read at all.
56
+ */
57
+ export const disclosesHealthDetail = (input: {
58
+ readonly config: HttpConfig;
59
+ readonly headers: Headers;
60
+ readonly socketAddress: string | null;
61
+ }): boolean => {
62
+ const peers = input.config.healthDetailPeers;
63
+ if (!healthPeerListed(peers, input.socketAddress)) return false;
64
+ const forwarded = forwardedClientAddress(input.headers, input.config);
65
+ return forwarded === undefined || healthPeerListed(peers, forwarded);
66
+ };
package/src/hooks.ts CHANGED
@@ -40,12 +40,13 @@ export interface ServerHooks {
40
40
  /**
41
41
  * The app's OWN error page for a status, served byte for byte, or `undefined` to render the
42
42
  * framework's. A seam and not a config value because the answer lives on a disk this package
43
- * cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html`, per request, so a file
44
- * dropped into a running server takes effect without a restart — the rule `/favicon.ico`
45
- * already follows for the same class of file.
43
+ * cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html` — per request under
44
+ * `x dev`, so a file dropped into a running server takes effect without a restart (the rule
45
+ * `/favicon.ico` already follows), and once per status in a container.
46
46
  *
47
- * Consulted only on the production HTML path: a dev process answers a browser with the overlay,
48
- * and an agent gets the problem document in both.
47
+ * Asked on the HTML path only — an agent gets the problem document in both environments. In
48
+ * production for every status; in dev for a 4xx alone (the page a visitor will see), with the
49
+ * overlay when it answers `undefined`. A dev 5xx is always the overlay and never asks.
49
50
  */
50
51
  readonly errorPage?: (
51
52
  status: number,
@@ -1,20 +1,6 @@
1
- // The two questions every HTML answer this package renders has to ask, in one place: is the caller
2
- // a browser, and how does a value become markup. Both were the dev overlay's private helpers while
3
- // the production error page needs the identical answers — a second accept sniff would let a client
4
- // get the overlay in dev and JSON in production, and a second escape set is one hole away.
5
-
6
- /**
7
- * `'` is escaped even though every attribute the renderers write is double-quoted: the escape set
8
- * is what the next author reads as the guarantee, and a single-quoted attribute written later
9
- * would inherit a hole nothing here would have flagged.
10
- */
11
- export const escapeHtml = (value: string): string =>
12
- value
13
- .replaceAll('&', '&amp;')
14
- .replaceAll('<', '&lt;')
15
- .replaceAll('>', '&gt;')
16
- .replaceAll('"', '&quot;')
17
- .replaceAll("'", '&#39;');
1
+ // Is the caller a browser? One sniff for every HTML answer this package renders — the dev overlay
2
+ // and the production error page need the identical answer, and a second sniff would let a client
3
+ // get the overlay in dev and JSON in production. How a value becomes markup is core's `escapeHtml`.
18
4
 
19
5
  /**
20
6
  * Does this caller render HTML? One sniff, three readers — the dev overlay, the production error
package/src/index.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  // The public surface of @ultimat3/http. Explicit, never `export *`: what is not
2
2
  // listed here is an implementation detail and may change without a major bump.
3
3
 
4
- export type { RenderMode } from '@ultimat3/core';
5
- // The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
6
- // the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
7
- // one, and neither may import the other. Re-exported here so a receiver route needs one import.
8
- export {
9
- isCanonicalWebhookField,
10
- WEBHOOK_ID_HEADER,
11
- WEBHOOK_SIGNATURE_HEADER,
12
- WEBHOOK_SIGNATURE_VERSION,
13
- WEBHOOK_TOPIC_HEADER,
14
- } from '@ultimat3/core';
4
+ // Imported bare, and the one module `sideEffects` lists: the codes' titles and the `retry-after`
5
+ // class of a 429 and a 503 are read by `UltimateError`, which never imports this module — so a
6
+ // server realm that reaches the barrel registers them whatever it constructs. `SIDE_EFFECTS_ANCHORS`
7
+ // carries the argument and `bun run side-effects` enforces it.
8
+ import './error-titles';
9
+
10
+ export type { PgExecutor, RenderMode } from '@ultimat3/core';
11
+ // No core VALUE is re-exported here (`X_HELPER_COPY`, `bun run flight-copies`): the webhook wire
12
+ // format (`WEBHOOK_*`, `isCanonicalWebhookField`), `escapeHtml` and `readCookie` are imported from
13
+ // `@ultimat3/core`, their one home, by every caller.
15
14
  export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
16
15
  export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
17
16
  export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
@@ -28,8 +27,8 @@ export type { ActorView, RequestContext, RequestContextInit } from './context';
28
27
  export {
29
28
  actorView,
30
29
  asCtx,
31
- createRequestContext,
32
30
  elapsedMs,
31
+ requestContext,
33
32
  useRequestBodyBytes,
34
33
  useRequestContext,
35
34
  useRequestCookie,
@@ -42,7 +41,7 @@ export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } fro
42
41
  export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
43
42
  export { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
44
43
  export type { Deadline } from './deadline';
45
- export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
44
+ export { resolveTimeoutMs, startDeadline } from './deadline';
46
45
  export type { ErrorFacts, ProblemDocument } from './error-facts';
47
46
  export {
48
47
  factsOf,
@@ -105,9 +104,8 @@ export {
105
104
  } from './forwarded';
106
105
  export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
107
106
  export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
108
- export { escapeHtml } from './html-render';
109
107
  export type { LocaleConfig, TimeZoneConfig } from './locale';
110
- export { DEFAULT_LOCALE_CONFIG, readCookie } from './locale';
108
+ export { DEFAULT_LOCALE_CONFIG } from './locale';
111
109
  export type { Middleware } from './middleware';
112
110
  export { compose } from './middleware';
113
111
  export type { NavigationPurpose } from './navigation';
@@ -124,7 +122,7 @@ export { OVERLAY_STYLE } from './overlay-style';
124
122
  export type { PeerIdentity } from './peer-identity';
125
123
  export { peerIdentity } from './peer-identity';
126
124
  export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
127
- export { createPipeline, PIPELINE_STAGES } from './pipeline';
125
+ export { httpPipeline, PIPELINE_STAGES } from './pipeline';
128
126
  export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
129
127
  export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
130
128
  export type {
@@ -141,11 +139,11 @@ export type {
141
139
  } from './rate-limit';
142
140
  export {
143
141
  assertRateLimitScope,
144
- createRateLimiter,
145
142
  DEFAULT_MAX_RATE_LIMIT_KEYS,
146
143
  DEFAULT_RATE_LIMIT,
147
144
  memoryRateLimitStore,
148
145
  rateLimitDecision,
146
+ rateLimiter,
149
147
  rateLimitSpends,
150
148
  resolveRateLimitConfig,
151
149
  toBucket,
@@ -161,11 +159,18 @@ export {
161
159
  rateLimitStoreUnavailable,
162
160
  tenantBucketUnknown,
163
161
  } from './rate-limit-errors';
164
- export type {
165
- PgExecutor,
166
- PostgresRateLimitStore,
167
- PostgresRateLimitStoreOptions,
168
- } from './rate-limit-postgres';
162
+ export { publishRateLimit, rateLimitHeaders } from './rate-limit-headers';
163
+ export type { AdoptRateLimitStoreOptions } from './rate-limit-installed';
164
+ export {
165
+ adoptRateLimitStore,
166
+ assertInstalledRateLimitScope,
167
+ installedRateLimitStore,
168
+ installRateLimitStore,
169
+ resetRateLimitStore,
170
+ } from './rate-limit-installed';
171
+ export type { RateLimitPeek } from './rate-limit-peek';
172
+ export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
173
+ export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
169
174
  export {
170
175
  postgresRateLimitStore,
171
176
  SQL_RATE_LIMIT_TABLE,
@@ -173,18 +178,20 @@ export {
173
178
  export { setRedirect, takeRedirect } from './redirect';
174
179
  export type { QueryValues } from './request';
175
180
  export { UltimateRequest } from './request';
181
+ export type { RequestFacts, RequestFactsInput } from './request-facts';
182
+ export { requestFacts } from './request-facts';
176
183
  export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
177
184
  export {
178
185
  applyCacheHeaders,
179
186
  cacheControl,
180
187
  html,
181
- json,
188
+ jsonResponse,
182
189
  NO_STORE,
183
190
  noContent,
184
191
  problem,
185
192
  redirect,
186
193
  stream,
187
- text,
194
+ textResponse,
188
195
  withHeaders,
189
196
  } from './response';
190
197
  export type {
@@ -199,9 +206,9 @@ export type {
199
206
  RouteTable,
200
207
  } from './router';
201
208
  export {
202
- createRouter,
203
209
  describeRoutes,
204
210
  HTTP_METHODS,
211
+ httpRouter,
205
212
  matchRoute,
206
213
  } from './router';
207
214
  export type { SecurityConfig } from './security-headers';
@@ -213,12 +220,14 @@ export type {
213
220
  UpgradeTarget,
214
221
  WebSocketMount,
215
222
  } from './server';
216
- export { createServer } from './server';
223
+ export { httpServer } from './server';
224
+ export type { DeleteCookieOptions } from './set-cookie';
225
+ export { deleteCookie, setCookie } from './set-cookie';
217
226
  // The stage vocabulary comes from its declaration site, beside the fourteen implementations it
218
227
  // names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
219
228
  export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
220
229
  export type { InferOutput, Schema, ValidationOutcome } from './validate';
221
- export { formatIssue, validate, validateSync } from './validate';
230
+ export { formatIssue, validateBody, validateSync } from './validate';
222
231
  export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
223
232
  export {
224
233
  DEFAULT_WEBHOOK_BODY_LIMIT,