@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.
- package/CLAUDE.md +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -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
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 :
|
|
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.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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 =
|
|
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
|
|
44
|
-
* dropped into a running server takes effect without a restart
|
|
45
|
-
* already follows
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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,
|
package/src/html-render.ts
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// the
|
|
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('&', '&')
|
|
14
|
-
.replaceAll('<', '<')
|
|
15
|
-
.replaceAll('>', '>')
|
|
16
|
-
.replaceAll('"', '"')
|
|
17
|
-
.replaceAll("'", ''');
|
|
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
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
188
|
+
jsonResponse,
|
|
182
189
|
NO_STORE,
|
|
183
190
|
noContent,
|
|
184
191
|
problem,
|
|
185
192
|
redirect,
|
|
186
193
|
stream,
|
|
187
|
-
|
|
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 {
|
|
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,
|
|
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,
|