@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/locale-prefix.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { type LocaleSources, localeConfig, resolveLocale, splitLocalePrefix } from '@ultimat3/i18n';
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
|
+
import { normalizePath } from './router';
|
|
8
9
|
|
|
9
10
|
/** Methods whose redirect may change nothing but the URL. Anything else is a 308, never a 301. */
|
|
10
11
|
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
@@ -26,7 +27,10 @@ export function routeLocalePrefix(
|
|
|
26
27
|
if (prefix === undefined) return pathname;
|
|
27
28
|
if (prefix.isDefault) {
|
|
28
29
|
const mount = basePath === '/' || basePath === '' ? '' : basePath.replace(/\/$/, '');
|
|
29
|
-
|
|
30
|
+
// Normalised exactly as the router would match it. The remainder is caller-written, and
|
|
31
|
+
// `//host/x` in a `Location` is a scheme-relative URL: a permanent, cacheable redirect off
|
|
32
|
+
// the site, answered before auth. One leading slash, no empty segment.
|
|
33
|
+
const location = `${mount}${normalizePath(prefix.path)}${ctx.url.search}`;
|
|
30
34
|
// PERMANENT, and so not `redirect()`, whose statuses are an application's (it refuses 301 on
|
|
31
35
|
// purpose: a cached permanent redirect cannot be taken back). This one is the framework's URL
|
|
32
36
|
// scheme, and it IS permanent — the default locale is never prefixed, in any release.
|
package/src/locale.ts
CHANGED
|
@@ -32,30 +32,3 @@ export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
|
|
|
32
32
|
header: TIMEZONE_HEADER,
|
|
33
33
|
cookie: 'x_timezone',
|
|
34
34
|
};
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The raw value on a malformed escape, never a throw. `Cookie:` is attacker-controlled and
|
|
38
|
-
* `decodeURIComponent('%')` is a bare `URIError` — thrown from the `locale` stage, which runs on
|
|
39
|
-
* every request, so `curl -H 'Cookie: x-locale=%'` answered 500 and paged the on-call. Nothing is
|
|
40
|
-
* loosened: a locale that is not in `supported` still falls back, and a time zone `Intl` cannot
|
|
41
|
-
* format is still refused. `@ultimat3/auth` guards its own cookie reader the same way and for the
|
|
42
|
-
* same reason; the guard is four lines and this package can never import that one (same tier).
|
|
43
|
-
*/
|
|
44
|
-
const decodeCookieValue = (raw: string): string => {
|
|
45
|
-
try {
|
|
46
|
-
return decodeURIComponent(raw);
|
|
47
|
-
} catch {
|
|
48
|
-
return raw;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export const readCookie = (header: string | null, name: string): string | null => {
|
|
53
|
-
if (header === null) return null;
|
|
54
|
-
for (const part of header.split(';')) {
|
|
55
|
-
const index = part.indexOf('=');
|
|
56
|
-
if (index === -1) continue;
|
|
57
|
-
if (part.slice(0, index).trim() !== name) continue;
|
|
58
|
-
return decodeCookieValue(part.slice(index + 1).trim());
|
|
59
|
-
}
|
|
60
|
-
return null;
|
|
61
|
-
};
|
package/src/navigation.ts
CHANGED
|
@@ -24,17 +24,42 @@ export function navigationPurpose(request: Request): NavigationPurpose | null {
|
|
|
24
24
|
|
|
25
25
|
const SAFE = new Set(['GET', 'HEAD']);
|
|
26
26
|
|
|
27
|
+
/** The schemes a navigation may be handed over to. Everything else is not a place to go. */
|
|
28
|
+
const WEB_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* What `x-ultimate-location` says. A target on THIS origin is its path, query and fragment and
|
|
29
32
|
* nothing else: behind a TLS-terminating proxy the request URL this process sees is
|
|
30
33
|
* `http://internal…`, so an absolute URL built from it named the public site as `http://` (HSTS
|
|
31
34
|
* hid it; it was still wrong). The router resolves a path against the page it runs in, whose origin
|
|
32
35
|
* is the one the visitor typed. A target on ANOTHER origin is kept exactly as the app gave it.
|
|
36
|
+
*
|
|
37
|
+
* Only `http:` and `https:` are ever handed over. The router LOADS what this header says, so a
|
|
38
|
+
* scheme that runs in the page (`javascript:`, `data:`) would be script in the app's own origin
|
|
39
|
+
* whenever a redirect target is caller-influenced. Such a target — and one that will not parse —
|
|
40
|
+
* answers the REQUESTED url instead, as a path: a document load of it re-runs the route, and the
|
|
41
|
+
* browser's own redirect handling refuses what this refused. Total, never a throw: it runs in the
|
|
42
|
+
* `response` stage, after the handler. A same-origin pathname is never emitted starting `//`
|
|
43
|
+
* (`hostlessPath`).
|
|
33
44
|
*/
|
|
34
45
|
export function locationFor(target: string, base: URL): string {
|
|
46
|
+
const requested = `${base.pathname}${base.search}${base.hash}`;
|
|
47
|
+
if (!URL.canParse(target, base)) return requested;
|
|
35
48
|
const resolved = new URL(target, base);
|
|
49
|
+
if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
|
|
36
50
|
if (resolved.origin !== base.origin) return target;
|
|
37
|
-
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
51
|
+
return `${hostlessPath(resolved.pathname)}${resolved.search}${resolved.hash}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A pathname that starts `//` — `/.//evil.test`, `/..//evil.test`, a request for `//evil.test` —
|
|
56
|
+
* is a path on THIS origin only while the URL keeps its host. Emitted alone it is a
|
|
57
|
+
* scheme-relative reference, and the router resolves it to another host and assigns that to
|
|
58
|
+
* `window.location`. `/.` in front is how WHATWG URL serialisation keeps a host-less `//` path a
|
|
59
|
+
* path; it resolves back to the same pathname, so nothing legitimate changes.
|
|
60
|
+
*/
|
|
61
|
+
function hostlessPath(pathname: string): string {
|
|
62
|
+
return pathname.startsWith('//') ? `/.${pathname}` : pathname;
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
/** "Load this with a real navigation" — never stored anywhere, it answers one request. */
|
package/src/overlay.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// `--json` emits, so a code/cause/fix string can never differ between the three
|
|
3
3
|
// surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
|
|
4
4
|
// error contract, not UI copy, so they are not routed through the i18n catalog.
|
|
5
|
+
|
|
6
|
+
import { escapeHtml } from '@ultimat3/core';
|
|
5
7
|
import { factsOf, renderErrorLines, toProblem } from './error-facts';
|
|
6
|
-
import { acceptsHtml
|
|
8
|
+
import { acceptsHtml } from './html-render';
|
|
7
9
|
import { OVERLAY_STYLE } from './overlay-style';
|
|
8
10
|
import { html } from './response';
|
|
9
11
|
|
package/src/peer-identity.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Who the MESH says is calling. TLS termination is the mesh's job (axiom 7) so the framework's
|
|
2
|
-
// entire contribution is reading a header it has been TOLD to trust:
|
|
3
|
-
// `x-forwarded-client-cert`, at the
|
|
4
|
-
// header is worse than nothing — it authenticates — so an untrusted read answers `null
|
|
5
|
-
// there is no second proxy-trust path to get it wrong in.
|
|
2
|
+
// entire contribution is reading a header it has been TOLD to trust (`trustClientCertHeader`):
|
|
3
|
+
// Envoy's `x-forwarded-client-cert`, at the hop index `x-forwarded-for` is read at. Untrusted,
|
|
4
|
+
// this header is worse than nothing — it authenticates — so an untrusted read answers `null`.
|
|
6
5
|
|
|
7
6
|
import { FORWARDED_CLIENT_CERT, type ForwardedInput, forwardedElement } from './forwarded';
|
|
8
7
|
|
|
@@ -82,11 +81,16 @@ const pairsOf = (element: string): ReadonlyMap<string, readonly string[]> => {
|
|
|
82
81
|
};
|
|
83
82
|
|
|
84
83
|
/**
|
|
85
|
-
* The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for
|
|
86
|
-
*
|
|
84
|
+
* The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for a deployment
|
|
85
|
+
* that did not declare `trustClientCertHeader`, an untrusted one, a missing header and a chain
|
|
86
|
+
* shorter than `trustedProxyHops` alike: a certificate
|
|
87
87
|
* identity read from a hop nobody vouched for is a confident name for an attacker's claim.
|
|
88
88
|
*/
|
|
89
89
|
export const peerIdentity = (input: ForwardedInput): PeerIdentity | null => {
|
|
90
|
+
// Its own declaration, never implied by `trustProxy`: that one says the proxy APPENDS to
|
|
91
|
+
// `x-forwarded-for`, and plenty of ingresses that do pass a client-sent certificate header
|
|
92
|
+
// straight through — where the caller would be naming its own identity.
|
|
93
|
+
if (!input.config.trustClientCertHeader) return null;
|
|
90
94
|
const element = forwardedElement(
|
|
91
95
|
input.headers.get(FORWARDED_CLIENT_CERT),
|
|
92
96
|
input.config.trustedProxyHops,
|
package/src/pipeline.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// middleware stack, so nothing can skip a stage and `/_x` and `pipeline.test.ts` can both read it.
|
|
4
4
|
// The other two thirds of the lifecycle are siblings: `stages.ts` owns what each stage does, and
|
|
5
5
|
// `finalize.ts` owns the promise that the tail always answers rather than rejecting.
|
|
6
|
-
import { recordRequest, runWithContext, withSpan } from '@ultimat3/core';
|
|
6
|
+
import { beginWork, recordRequest, runWithContext, withSpan } from '@ultimat3/core';
|
|
7
7
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
8
|
-
import { asCtx,
|
|
8
|
+
import { asCtx, elapsedMs, type RequestContext, requestContext } from './context';
|
|
9
9
|
import { readCorrelation } from './correlation';
|
|
10
10
|
import { type Deadline, startDeadline } from './deadline';
|
|
11
11
|
import { pipelineNoResponse } from './errors';
|
|
@@ -14,8 +14,9 @@ import { clientAddress, clientUsedHttps } from './forwarded';
|
|
|
14
14
|
import type { ServerHooks } from './hooks';
|
|
15
15
|
import type { Middleware } from './middleware';
|
|
16
16
|
import { peerIdentity } from './peer-identity';
|
|
17
|
-
import { assertRateLimitScope,
|
|
17
|
+
import { assertRateLimitScope, type RateLimiter, rateLimiter } from './rate-limit';
|
|
18
18
|
import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
19
|
+
import { assertInstalledRateLimitScope } from './rate-limit-installed';
|
|
19
20
|
import { UltimateRequest } from './request';
|
|
20
21
|
import { problem } from './response';
|
|
21
22
|
import type { RouteTable } from './router';
|
|
@@ -56,7 +57,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
56
57
|
{
|
|
57
58
|
name: 'auth',
|
|
58
59
|
phase: 'request',
|
|
59
|
-
why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address',
|
|
60
|
+
why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address — and a request that FAILS a required route is refused here, so this stage spends an allowance keyed on the address alone for it: a failed credential is metered, a signed-in caller behind the same address is not',
|
|
60
61
|
},
|
|
61
62
|
{
|
|
62
63
|
name: 'rate-limit',
|
|
@@ -66,7 +67,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
66
67
|
{
|
|
67
68
|
name: 'csrf',
|
|
68
69
|
phase: 'request',
|
|
69
|
-
why: 'after auth so it
|
|
70
|
+
why: 'after auth so it knows who is calling: a bearer token is exempt (not ambient), and an anonymous write is judged like a signed-in one whenever it carries browser evidence, because a forged sign-in is a forged write — and before body so a forged write never makes the server allocate its payload. CORS cannot cover this: application/x-www-form-urlencoded is a simple content type, so a cross-site form post is sent and executed and only the RESPONSE is withheld',
|
|
70
71
|
},
|
|
71
72
|
{
|
|
72
73
|
name: 'body',
|
|
@@ -109,7 +110,7 @@ export interface PipelineDeps {
|
|
|
109
110
|
readonly hooks?: ServerHooks;
|
|
110
111
|
readonly middleware?: readonly Middleware[];
|
|
111
112
|
/**
|
|
112
|
-
* A limiter built elsewhere — `
|
|
113
|
+
* A limiter built elsewhere — `httpServer({ rateLimitStore })` is the one supported way, and
|
|
113
114
|
* it hands over a limiter built from the SAME merged config this constructor would have built.
|
|
114
115
|
* One passed from anywhere else resolves bucket names against the table IT closed over, which
|
|
115
116
|
* is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
|
|
@@ -135,16 +136,19 @@ export interface Pipeline {
|
|
|
135
136
|
handle(request: Request, init: HandleInit): Promise<Response>;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export const
|
|
139
|
+
export const httpPipeline = (deps: PipelineDeps): Pipeline => {
|
|
139
140
|
// Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
|
|
140
141
|
// bucket a route declares only becomes real at the construction that holds both. Idempotent, so
|
|
141
|
-
// a config `
|
|
142
|
+
// a config `httpServer` already merged passes through unchanged.
|
|
142
143
|
const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
|
|
143
|
-
const limiter = deps.limiter ??
|
|
144
|
-
// Here rather than in `
|
|
144
|
+
const limiter = deps.limiter ?? rateLimiter({ config: config.rateLimit });
|
|
145
|
+
// Here rather than in `httpServer`: this is the one construction path every server, test and
|
|
145
146
|
// embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
|
|
146
147
|
// Two halves of one question — where the counters live, and which buckets the limiter holds.
|
|
147
148
|
assertRateLimitScope(config.rateLimit, limiter);
|
|
149
|
+
// The third half: where an action's or a query's declared `rateLimit:` is counted. Under a
|
|
150
|
+
// `'shared'` declaration that store is held to the same promise as this pipeline's own.
|
|
151
|
+
assertInstalledRateLimitScope(config.rateLimit);
|
|
148
152
|
assertRouteBuckets(limiter, deps.table.routes);
|
|
149
153
|
const run = stageRunners({
|
|
150
154
|
table: deps.table,
|
|
@@ -188,8 +192,23 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
188
192
|
// own; this race is the half that answers the SOCKET when it does not. `work` keeps its
|
|
189
193
|
// own handler either way — a rejection arriving after the deadline already won is still
|
|
190
194
|
// a rejection, and an unhandled one takes the process down.
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
let settled = false;
|
|
196
|
+
const settle = (): void => {
|
|
197
|
+
settled = true;
|
|
198
|
+
};
|
|
199
|
+
void work.then(settle, settle);
|
|
200
|
+
try {
|
|
201
|
+
await Promise.race([work, deadline.expired]);
|
|
202
|
+
} finally {
|
|
203
|
+
// The deadline won and the handler is still running: it holds its pool slot and
|
|
204
|
+
// whatever it was writing. The caller's own count ends when this response leaves, so
|
|
205
|
+
// the orphan is counted in its own right until it settles — a drain that saw an idle
|
|
206
|
+
// process here closed the pool under it.
|
|
207
|
+
if (!settled) {
|
|
208
|
+
const done = beginWork();
|
|
209
|
+
void work.then(done, done);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
193
212
|
}
|
|
194
213
|
} catch (error) {
|
|
195
214
|
ctx.error = error;
|
|
@@ -228,7 +247,7 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
228
247
|
socketAddress: init.ip ?? null,
|
|
229
248
|
urlProtocol: url.protocol,
|
|
230
249
|
};
|
|
231
|
-
const ctx =
|
|
250
|
+
const ctx = requestContext({
|
|
232
251
|
url,
|
|
233
252
|
method: raw.method,
|
|
234
253
|
role: init.role,
|
package/src/preferences.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// and `auth` calls it again once the actor's saved preference is known. WHERE each value is read
|
|
3
3
|
// from is this file's; what a locale or a zone IS stays `@ultimat3/i18n`'s and `@ultimat3/time`'s.
|
|
4
4
|
|
|
5
|
+
import { readCookie } from '@ultimat3/core';
|
|
5
6
|
import { resolveTimeZone } from '@ultimat3/time';
|
|
6
7
|
import type { HttpConfig } from './config';
|
|
7
8
|
import type { RequestContext } from './context';
|
|
8
|
-
import { readCookie } from './locale';
|
|
9
9
|
import { requestLocale } from './locale-prefix';
|
|
10
10
|
import type { UltimateRequest } from './request';
|
|
11
11
|
|
package/src/problem-meta.ts
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// it was. Measured need: an app's `X_SESSION_CHECKOUT_BUSY` carried `{ sessionId, title, state }`
|
|
13
13
|
// in `meta` and its island recovered the id by running a UUID regex over `cause`.
|
|
14
14
|
|
|
15
|
+
// The public-cause predicate and both of its tables are `@ultimat3/core`'s (`public-cause.ts`): the
|
|
16
|
+
// MCP error data and the agent `tool_result` ask the question this package's problem document
|
|
17
|
+
// does. This file only WRITES the app half — a reader imports `hasPublicCause` from core.
|
|
18
|
+
import { registerPublicCause, resetPublicCauses } from '@ultimat3/core';
|
|
15
19
|
import { ERROR_STATUS } from './error-map';
|
|
16
20
|
import { problemMetaInvalid } from './errors';
|
|
17
21
|
|
|
@@ -47,20 +51,6 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
|
|
|
47
51
|
/** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
|
|
48
52
|
const DECLARED = new Map<string, readonly string[]>();
|
|
49
53
|
|
|
50
|
-
/**
|
|
51
|
-
* The 5xx codes whose `cause` a caller may read. Every other 5xx document carries the code and the
|
|
52
|
-
* request id and a fixed sentence: `X_DB_STATEMENT_FAILED` has a status row, so the old "blank only
|
|
53
|
-
* what nobody classified" rule served the Postgres message and the SQL statement in a production
|
|
54
|
-
* 500. The framework's four are refusals whose cause IS the instruction — back off, retry.
|
|
55
|
-
*/
|
|
56
|
-
const FRAMEWORK_PUBLIC_CAUSE: ReadonlySet<string> = new Set([
|
|
57
|
-
'X_DRAINING',
|
|
58
|
-
'X_OVERLOADED',
|
|
59
|
-
'X_FLIGHT_GATE_OVERLOADED',
|
|
60
|
-
'X_TIMEOUT',
|
|
61
|
-
]);
|
|
62
|
-
const APP_PUBLIC_CAUSE = new Set<string>();
|
|
63
|
-
|
|
64
54
|
/** Per code: the `meta` keys, a public cause, or both. A bare list is the keys alone. */
|
|
65
55
|
export type ProblemMetaDeclaration =
|
|
66
56
|
| readonly string[]
|
|
@@ -98,7 +88,7 @@ export const registerProblemMeta = (
|
|
|
98
88
|
: ((declaration as { keys?: readonly string[] }).keys ?? []);
|
|
99
89
|
const publicCause = !listed && (declaration as { publicCause?: boolean }).publicCause === true;
|
|
100
90
|
if (!listed && keys.length === 0 && publicCause) {
|
|
101
|
-
|
|
91
|
+
registerPublicCause(code);
|
|
102
92
|
continue;
|
|
103
93
|
}
|
|
104
94
|
if (keys.length === 0) {
|
|
@@ -122,20 +112,16 @@ export const registerProblemMeta = (
|
|
|
122
112
|
throw problemMetaInvalid(code, `already declared as [${existing.join(', ')}] by this app`);
|
|
123
113
|
}
|
|
124
114
|
DECLARED.set(code, [...keys]);
|
|
125
|
-
if (publicCause)
|
|
115
|
+
if (publicCause) registerPublicCause(code);
|
|
126
116
|
}
|
|
127
117
|
};
|
|
128
118
|
|
|
129
119
|
/** Test seam. Production registers once at boot and never unregisters. */
|
|
130
120
|
export const resetProblemMeta = (): void => {
|
|
131
121
|
DECLARED.clear();
|
|
132
|
-
|
|
122
|
+
resetPublicCauses();
|
|
133
123
|
};
|
|
134
124
|
|
|
135
|
-
/** Whether a 5xx document for `code` may carry its authored `cause`. */
|
|
136
|
-
export const hasPublicCause = (code: string): boolean =>
|
|
137
|
-
FRAMEWORK_PUBLIC_CAUSE.has(code) || APP_PUBLIC_CAUSE.has(code);
|
|
138
|
-
|
|
139
125
|
/** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
|
|
140
126
|
export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
|
|
141
127
|
DECLARED.get(code);
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import type { HttpConfig } from './config';
|
|
7
7
|
import type { Bucket, RateLimiter } from './rate-limit';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
rateLimitBucketConflict,
|
|
10
|
+
rateLimitBucketForPrimitive,
|
|
11
|
+
rateLimitBucketUnbound,
|
|
12
|
+
} from './rate-limit-errors';
|
|
9
13
|
import type { Route } from './router';
|
|
10
14
|
|
|
11
15
|
const same = (a: Bucket, b: Bucket): boolean =>
|
|
@@ -20,10 +24,30 @@ const same = (a: Bucket, b: Bucket): boolean =>
|
|
|
20
24
|
* number an author read, an OpenAPI document published and nothing enforced, which is the exact
|
|
21
25
|
* failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
|
|
22
26
|
*
|
|
23
|
-
* Idempotent, so both construction paths (`
|
|
27
|
+
* Idempotent, so both construction paths (`httpServer`, and `httpPipeline` under it) can apply
|
|
24
28
|
* it: a second pass compares each bucket against the copy the first pass registered.
|
|
25
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* A configured bucket keyed by a mounted primitive's NAME, which since 25.0.0 limits nothing.
|
|
32
|
+
* `default`, the tenant bucket and any name a route really selects are live, so they pass.
|
|
33
|
+
*/
|
|
34
|
+
const assertNoPrimitiveBuckets = (config: HttpConfig, routes: readonly Route[]): void => {
|
|
35
|
+
const selected = new Set<string>(['default']);
|
|
36
|
+
if (config.rateLimit.tenantBucket !== null) selected.add(config.rateLimit.tenantBucket);
|
|
37
|
+
for (const route of routes) {
|
|
38
|
+
if (route.meta.rateLimit !== undefined) selected.add(route.meta.rateLimit);
|
|
39
|
+
}
|
|
40
|
+
for (const route of routes) {
|
|
41
|
+
const primitive = route.meta.primitive;
|
|
42
|
+
const name = route.meta.name;
|
|
43
|
+
if (primitive === undefined || selected.has(name)) continue;
|
|
44
|
+
if (!Object.hasOwn(config.rateLimit.buckets, name)) continue;
|
|
45
|
+
throw rateLimitBucketForPrimitive({ bucket: name, primitive });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
26
49
|
export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
|
|
50
|
+
assertNoPrimitiveBuckets(config, routes);
|
|
27
51
|
const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
|
|
28
52
|
for (const route of routes) {
|
|
29
53
|
const bucket = route.meta.rateLimitBucket;
|
package/src/rate-limit-errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration
|
|
2
2
|
// faults the boot refuses. Split from `errors.ts` at the 500-line ceiling, on the seam it already
|
|
3
|
-
// had. The codes
|
|
4
|
-
// must see them all in a single call.
|
|
3
|
+
// had. The codes, their TITLES and the `retry-after` class are `error-titles.ts`'s, the one
|
|
4
|
+
// registry — `registerErrorCodes` must see them all in a single call.
|
|
5
5
|
import { HttpError } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -19,19 +19,24 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* At `
|
|
22
|
+
* At `httpServer`/`httpPipeline`, never on the request. `replicas: 3` behind one config means
|
|
23
23
|
* each process holds its own counters, so every configured number is enforced three times over —
|
|
24
24
|
* a green `x verify` and a limit that is not the limit. The declaration is the app's because the
|
|
25
25
|
* framework cannot see its replica count, and a framework that guessed would guess wrong.
|
|
26
26
|
*/
|
|
27
|
-
export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
|
|
27
|
+
export const rateLimitNotShared = (found: 'process' | 'disabled' | 'installed'): HttpError =>
|
|
28
28
|
new HttpError({
|
|
29
29
|
code: 'X_RATE_LIMIT_NOT_SHARED',
|
|
30
30
|
cause:
|
|
31
31
|
found === 'disabled'
|
|
32
32
|
? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
|
|
33
|
-
:
|
|
34
|
-
|
|
33
|
+
: found === 'installed'
|
|
34
|
+
? "http.rateLimit.scope is 'shared' but the store actions and queries spend their declared rateLimit from keeps its counters in this process, so each replica would enforce every declared limit on its own"
|
|
35
|
+
: "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
|
|
36
|
+
fix:
|
|
37
|
+
found === 'installed'
|
|
38
|
+
? 'installRateLimitStore(rateLimitStore) at boot, with the same postgresRateLimitStore({ executor }) passed to httpServer({ routes, rateLimitStore })'
|
|
39
|
+
: "httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
/**
|
|
@@ -46,7 +51,28 @@ interface BucketNumbers {
|
|
|
46
51
|
const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
|
-
*
|
|
54
|
+
* `rateLimit.buckets.<name>` where `<name>` is a mounted action's or query's own name. Since
|
|
55
|
+
* 25.0.0 a primitive's route carries no `meta.rateLimit` — the primitive spends its own declared
|
|
56
|
+
* limit on every surface — so that bucket limits nothing. Refused rather than kept: a number an
|
|
57
|
+
* operator reads in `configureHttp()` and nothing enforces is the failure every 12a deletion turns
|
|
58
|
+
* into a code. `X_CONFIG_INVALID`, borrowed, because it is a config key the framework cannot honour.
|
|
59
|
+
*/
|
|
60
|
+
export const rateLimitBucketForPrimitive = (input: {
|
|
61
|
+
bucket: string;
|
|
62
|
+
primitive: 'action' | 'query';
|
|
63
|
+
}): HttpError =>
|
|
64
|
+
new HttpError({
|
|
65
|
+
code: 'X_CONFIG_INVALID',
|
|
66
|
+
cause: `rateLimit.buckets.${input.bucket} is named after the mounted ${input.primitive} "${input.bucket}", and since 25.0.0 an ${input.primitive}'s route spends no named bucket — the ${input.primitive} spends its own declared rateLimit on every surface — so this bucket would limit nothing`,
|
|
67
|
+
fix:
|
|
68
|
+
input.primitive === 'query'
|
|
69
|
+
? `query({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits a query since 25.0.0 — delete it from configureHttp() and declare the limit on the query`
|
|
70
|
+
: `action({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits an action since 25.0.0 — delete it from configureHttp() and declare the limit on the action`,
|
|
71
|
+
meta: { option: `rateLimit.buckets.${input.bucket}`, primitive: input.primitive },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Two declarations of one bucket, at `httpServer`/`httpPipeline`. Neither wins: an app that
|
|
50
76
|
* configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
|
|
51
77
|
* disagree about what is enforced, and whichever a merge picked would leave the other a number
|
|
52
78
|
* someone read and nothing applies — the failure this seam exists to end. The message speaks
|
|
@@ -84,7 +110,7 @@ export const rateLimitBucketConflict = (input: {
|
|
|
84
110
|
|
|
85
111
|
/**
|
|
86
112
|
* A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
|
|
87
|
-
* `
|
|
113
|
+
* `httpPipeline`, never on the request. `rateLimiter` closes over the config it was built
|
|
88
114
|
* with, so a limiter constructed before the routes existed resolves the route's bucket name
|
|
89
115
|
* through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
|
|
90
116
|
* requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
|
|
@@ -109,7 +135,7 @@ export const rateLimitBucketUnbound = (input: {
|
|
|
109
135
|
? 'does not hold that bucket, so the route would run on the default one'
|
|
110
136
|
: `holds ${numbers(input.found)} for it`
|
|
111
137
|
}`,
|
|
112
|
-
fix: 'pass the STORE and let the pipeline build the limiter —
|
|
138
|
+
fix: 'pass the STORE and let the pipeline build the limiter — httpServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
|
|
113
139
|
});
|
|
114
140
|
|
|
115
141
|
/**
|
|
@@ -125,7 +151,7 @@ export const rateLimitScopeUnset = (): HttpError =>
|
|
|
125
151
|
code: 'X_RATE_LIMIT_SCOPE_UNSET',
|
|
126
152
|
cause:
|
|
127
153
|
'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
|
|
128
|
-
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus
|
|
154
|
+
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
|
|
129
155
|
});
|
|
130
156
|
|
|
131
157
|
/**
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A rate-limit decision as the three `RateLimit-*` response headers. One rendering, so the
|
|
2
|
+
// pipeline's limiter, a bearer mount's per-token allowance and a primitive's declared bucket can
|
|
3
|
+
// never answer a client in two shapes.
|
|
4
|
+
|
|
5
|
+
import type { RequestContext } from './context';
|
|
6
|
+
import type { RateLimitDecision } from './rate-limit';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lowercase names, as every writer sets them on `ctx.headers`. The reset is whole seconds from
|
|
10
|
+
* `nowMs`, floored at 0: a decision read after its own reset passed is a full bucket, not a
|
|
11
|
+
* negative wait.
|
|
12
|
+
*/
|
|
13
|
+
export const rateLimitHeaders = (
|
|
14
|
+
decision: RateLimitDecision,
|
|
15
|
+
nowMs: number,
|
|
16
|
+
): Record<string, string> => ({
|
|
17
|
+
'ratelimit-limit': String(decision.limit),
|
|
18
|
+
'ratelimit-remaining': String(decision.remaining),
|
|
19
|
+
'ratelimit-reset': String(Math.max(0, Math.ceil((decision.resetAtMs - nowMs) / 1000))),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A handler-spent bucket onto the answer (`RouteMeta.rateLimitedBy: 'handler'`), when it is the
|
|
24
|
+
* one closest to refusing — the rule the `rate-limit` stage follows across its own buckets:
|
|
25
|
+
* `remaining: 118` off the tenant's while the primitive's holds 1 tells a client to proceed and
|
|
26
|
+
* then refuses its next call. A refusal always wins. Set on the CONTEXT before the handler throws,
|
|
27
|
+
* so the `error-map` stage's 429 carries these headers and reads `Retry-After` off `ctx.rateLimit`.
|
|
28
|
+
*/
|
|
29
|
+
export const publishRateLimit = (
|
|
30
|
+
ctx: RequestContext,
|
|
31
|
+
decision: RateLimitDecision,
|
|
32
|
+
nowMs: number,
|
|
33
|
+
): void => {
|
|
34
|
+
const prior = ctx.rateLimit;
|
|
35
|
+
if (decision.allowed && prior !== undefined && prior.remaining < decision.remaining) return;
|
|
36
|
+
ctx.rateLimit = decision;
|
|
37
|
+
for (const [name, value] of Object.entries(rateLimitHeaders(decision, nowMs))) {
|
|
38
|
+
ctx.headers.set(name, value);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The store a PRIMITIVE's declared `rateLimit:` is counted in — `@ultimat3/action`'s `invoke` and
|
|
2
|
+
// `@ultimat3/query`'s read path both spend from it, on every surface. One slot, here, so the two
|
|
3
|
+
// tier-3 packages (which cannot import each other) count in one place, and the boot fills it with
|
|
4
|
+
// the same instance it hands `httpServer({ rateLimitStore })`.
|
|
5
|
+
|
|
6
|
+
import type { RateLimitConfig, RateLimitStore } from './rate-limit';
|
|
7
|
+
import { memoryRateLimitStore } from './rate-limit';
|
|
8
|
+
import { rateLimitNotShared } from './rate-limit-errors';
|
|
9
|
+
|
|
10
|
+
/** What a process falls back to when nothing chose: one process' memory. */
|
|
11
|
+
let base: RateLimitStore = memoryRateLimitStore();
|
|
12
|
+
let baseChosen = false;
|
|
13
|
+
/**
|
|
14
|
+
* Adopted stores, newest last. A STACK of frames, each popped only by its own adopter: two
|
|
15
|
+
* servers started A-then-B and stopped A-then-B used to have A's stop restore what A replaced —
|
|
16
|
+
* reverting the slot under B while B still served. A frame object is its own identity, so the
|
|
17
|
+
* same store adopted twice is two frames.
|
|
18
|
+
*/
|
|
19
|
+
const frames: { readonly store: RateLimitStore }[] = [];
|
|
20
|
+
|
|
21
|
+
const current = (): RateLimitStore => frames.at(-1)?.store ?? base;
|
|
22
|
+
const isChosen = (): boolean => baseChosen || frames.length > 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The embedder's one-line install, for a process that builds its own boot: the base everything
|
|
26
|
+
* else stacks over. Left at the default it is one process' memory — right for dev and tests, and
|
|
27
|
+
* N × every declared limit behind N replicas, which `assertInstalledRateLimitScope` refuses under
|
|
28
|
+
* a `'shared'` declaration. A boot that has to give the slot back uses `adoptRateLimitStore`.
|
|
29
|
+
*/
|
|
30
|
+
export const installRateLimitStore = (store: RateLimitStore): void => {
|
|
31
|
+
base = store;
|
|
32
|
+
baseChosen = true;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const installedRateLimitStore = (): RateLimitStore => current();
|
|
36
|
+
|
|
37
|
+
/** Test-only. Every frame and the base, back to one process' memory. */
|
|
38
|
+
export const resetRateLimitStore = (): void => {
|
|
39
|
+
frames.length = 0;
|
|
40
|
+
base = memoryRateLimitStore();
|
|
41
|
+
baseChosen = false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface AdoptRateLimitStoreOptions {
|
|
45
|
+
/**
|
|
46
|
+
* `httpServer`'s rule: never over a store something already CHOSE. The boot installs its own
|
|
47
|
+
* before any server exists, and a server handed a different one must not move every action's
|
|
48
|
+
* counters out from under it — the chosen one stands, and `assertInstalledRateLimitScope` still
|
|
49
|
+
* holds it to the declaration.
|
|
50
|
+
*/
|
|
51
|
+
readonly keepChosen?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Push `store` as the one primitives spend from, and answer the undo that removes THIS frame and
|
|
56
|
+
* no other — whatever order the adopters stop in. The boot (`startServices`, `startRoles`) and
|
|
57
|
+
* `httpServer` all go through it, so every install has a matching, order-proof release.
|
|
58
|
+
*/
|
|
59
|
+
export const adoptRateLimitStore = (
|
|
60
|
+
store: RateLimitStore,
|
|
61
|
+
options: AdoptRateLimitStoreOptions = {},
|
|
62
|
+
): (() => void) => {
|
|
63
|
+
if (options.keepChosen === true && isChosen() && current() !== store) return () => undefined;
|
|
64
|
+
const frame = { store };
|
|
65
|
+
frames.push(frame);
|
|
66
|
+
return () => {
|
|
67
|
+
const at = frames.indexOf(frame);
|
|
68
|
+
if (at !== -1) frames.splice(at, 1);
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `assertRateLimitScope`'s rule for the primitives' half: a `'shared'` declaration says every
|
|
74
|
+
* number is the fleet's, and an action or query limit counted in one process' memory is not.
|
|
75
|
+
*/
|
|
76
|
+
export const assertInstalledRateLimitScope = (config: RateLimitConfig): void => {
|
|
77
|
+
if (config.scope !== 'shared') return;
|
|
78
|
+
if (current().scope !== 'shared') throw rateLimitNotShared('installed');
|
|
79
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The READ half of a token bucket: what a key holds now, refill applied, with nothing written.
|
|
2
|
+
// Split from `rate-limit.ts` at the size ceiling; every `RateLimitStore.peek` answers through it.
|
|
3
|
+
|
|
4
|
+
import type { Bucket } from './rate-limit';
|
|
5
|
+
|
|
6
|
+
/** A read of a bucket: whole tokens left, and how long until one more is there when none is. */
|
|
7
|
+
export interface RateLimitPeek {
|
|
8
|
+
readonly remaining: number;
|
|
9
|
+
/** `0` while a whole token is left; otherwise the seconds until one is, at least 1. */
|
|
10
|
+
readonly retryAfterSeconds: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The peek every store answers from the tokens it computed — exported for the reason
|
|
15
|
+
* `rateLimitDecision` is: two drivers deriving "when may I come back" separately is two answers.
|
|
16
|
+
* A bucket that never refills is clamped to a day, as a decision's `Retry-After` is.
|
|
17
|
+
*/
|
|
18
|
+
export const rateLimitPeek = (bucket: Bucket, tokens: number): RateLimitPeek => {
|
|
19
|
+
if (tokens >= 1) return { remaining: Math.floor(tokens), retryAfterSeconds: 0 };
|
|
20
|
+
const seconds =
|
|
21
|
+
bucket.refillPerSecond > 0 ? Math.min(86_400, (1 - tokens) / bucket.refillPerSecond) : 86_400;
|
|
22
|
+
return { remaining: 0, retryAfterSeconds: Math.max(1, Math.ceil(seconds)) };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `stored` tokens last written at `lastMs`, refilled to `nowMs` and capped — no mutation. */
|
|
26
|
+
export const refilledTokens = (
|
|
27
|
+
bucket: Bucket,
|
|
28
|
+
stored: number,
|
|
29
|
+
lastMs: number,
|
|
30
|
+
nowMs: number,
|
|
31
|
+
): number =>
|
|
32
|
+
Math.min(bucket.capacity, stored + (Math.max(0, nowMs - lastMs) / 1000) * bucket.refillPerSecond);
|