@ultimat3/http 23.0.0 → 24.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 +40 -14
- package/README.md +30 -9
- package/package.json +5 -5
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +36 -4
- package/src/context.ts +1 -1
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +8 -3
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +60 -0
- package/src/error-map-tier-1.ts +71 -0
- package/src/error-map-tier-2.ts +118 -0
- package/src/error-map-tier-3.ts +92 -0
- package/src/error-map-tier-4.ts +92 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +1 -2
- package/src/errors.ts +3 -3
- package/src/forwarded.ts +16 -5
- package/src/health-disclosure.ts +69 -0
- package/src/html-render.ts +3 -17
- package/src/index.ts +7 -8
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +13 -0
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +20 -5
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +27 -18
- package/src/rate-limit-stage.ts +123 -0
- package/src/rate-limit.ts +18 -0
- package/src/request.ts +10 -3
- package/src/response.ts +8 -0
- package/src/router.ts +20 -7
- package/src/server.ts +27 -6
- package/src/stages.ts +35 -45
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
/** Core's, named here for the config that defaults to it — never a second copy of the list. */
|
|
16
|
+
export { DEFAULT_HEALTH_DETAIL_PEERS } from '@ultimat3/core';
|
|
17
|
+
|
|
18
|
+
/** Every class an entry may name. A `Record` so a class core adds is a build error here. */
|
|
19
|
+
const ADDRESS_CLASSES = Object.freeze<Record<AddressClass, true>>({
|
|
20
|
+
loopback: true,
|
|
21
|
+
private: true,
|
|
22
|
+
'link-local': true,
|
|
23
|
+
ula: true,
|
|
24
|
+
cgnat: true,
|
|
25
|
+
unspecified: true,
|
|
26
|
+
reserved: true,
|
|
27
|
+
public: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const isAddressClass = (entry: string): entry is AddressClass =>
|
|
31
|
+
Object.hasOwn(ADDRESS_CLASSES, entry);
|
|
32
|
+
|
|
33
|
+
/** Beside its one caller: `errors.ts` is at the 500-line ceiling (`server.ts` does the same). */
|
|
34
|
+
const healthDetailPeersInvalid = (value: unknown): HttpError =>
|
|
35
|
+
new HttpError({
|
|
36
|
+
code: 'X_CONFIG_INVALID',
|
|
37
|
+
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`,
|
|
38
|
+
fix: "configureHttp({ healthDetailPeers: ['loopback', 'private'] })",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The declared list, or the refusal naming the entry no address can ever match. */
|
|
42
|
+
export const assertHealthDetailPeers = (peers: readonly string[]): readonly string[] => {
|
|
43
|
+
const declared: unknown = peers;
|
|
44
|
+
if (!Array.isArray(declared)) throw healthDetailPeersInvalid(declared);
|
|
45
|
+
for (const entry of declared as readonly unknown[]) {
|
|
46
|
+
if (typeof entry !== 'string') throw healthDetailPeersInvalid(entry);
|
|
47
|
+
if (!isAddressClass(entry) && classifyAddress(entry) === undefined) {
|
|
48
|
+
throw healthDetailPeersInvalid(entry);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...(declared as readonly string[])];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether this caller is told the detail. The SOCKET must be listed — and, when a declared proxy
|
|
56
|
+
* named a caller, that caller too: a proxy on this box makes every socket loopback, and a direct
|
|
57
|
+
* caller forging `x-forwarded-for` still arrives on a socket nobody listed. Undeclared, the
|
|
58
|
+
* header is not read at all.
|
|
59
|
+
*/
|
|
60
|
+
export const disclosesHealthDetail = (input: {
|
|
61
|
+
readonly config: HttpConfig;
|
|
62
|
+
readonly headers: Headers;
|
|
63
|
+
readonly socketAddress: string | null;
|
|
64
|
+
}): boolean => {
|
|
65
|
+
const peers = input.config.healthDetailPeers;
|
|
66
|
+
if (!healthPeerListed(peers, input.socketAddress)) return false;
|
|
67
|
+
const forwarded = forwardedClientAddress(input.headers, input.config);
|
|
68
|
+
return forwarded === undefined || healthPeerListed(peers, forwarded);
|
|
69
|
+
};
|
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,12 +1,14 @@
|
|
|
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';
|
|
4
|
+
export type { PgExecutor, RenderMode } from '@ultimat3/core';
|
|
5
5
|
// The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
|
|
6
6
|
// the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
|
|
7
7
|
// one, and neither may import the other. Re-exported here so a receiver route needs one import.
|
|
8
8
|
export {
|
|
9
|
+
escapeHtml,
|
|
9
10
|
isCanonicalWebhookField,
|
|
11
|
+
readCookie,
|
|
10
12
|
WEBHOOK_ID_HEADER,
|
|
11
13
|
WEBHOOK_SIGNATURE_HEADER,
|
|
12
14
|
WEBHOOK_SIGNATURE_VERSION,
|
|
@@ -105,9 +107,8 @@ export {
|
|
|
105
107
|
} from './forwarded';
|
|
106
108
|
export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
|
|
107
109
|
export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
|
|
108
|
-
export { escapeHtml } from './html-render';
|
|
109
110
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
110
|
-
export { DEFAULT_LOCALE_CONFIG
|
|
111
|
+
export { DEFAULT_LOCALE_CONFIG } from './locale';
|
|
111
112
|
export type { Middleware } from './middleware';
|
|
112
113
|
export { compose } from './middleware';
|
|
113
114
|
export type { NavigationPurpose } from './navigation';
|
|
@@ -161,11 +162,9 @@ export {
|
|
|
161
162
|
rateLimitStoreUnavailable,
|
|
162
163
|
tenantBucketUnknown,
|
|
163
164
|
} from './rate-limit-errors';
|
|
164
|
-
export type {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
PostgresRateLimitStoreOptions,
|
|
168
|
-
} from './rate-limit-postgres';
|
|
165
|
+
export type { RateLimitPeek } from './rate-limit-peek';
|
|
166
|
+
export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
167
|
+
export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
|
|
169
168
|
export {
|
|
170
169
|
postgresRateLimitStore,
|
|
171
170
|
SQL_RATE_LIMIT_TABLE,
|
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,15 +24,28 @@ 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.
|
|
33
43
|
*/
|
|
34
44
|
export function locationFor(target: string, base: URL): string {
|
|
45
|
+
const requested = `${base.pathname}${base.search}${base.hash}`;
|
|
46
|
+
if (!URL.canParse(target, base)) return requested;
|
|
35
47
|
const resolved = new URL(target, base);
|
|
48
|
+
if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
|
|
36
49
|
if (resolved.origin !== base.origin) return target;
|
|
37
50
|
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
38
51
|
}
|
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,7 +3,7 @@
|
|
|
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
8
|
import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
|
|
9
9
|
import { readCorrelation } from './correlation';
|
|
@@ -56,7 +56,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
56
56
|
{
|
|
57
57
|
name: 'auth',
|
|
58
58
|
phase: 'request',
|
|
59
|
-
why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address',
|
|
59
|
+
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
60
|
},
|
|
61
61
|
{
|
|
62
62
|
name: 'rate-limit',
|
|
@@ -66,7 +66,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
|
|
|
66
66
|
{
|
|
67
67
|
name: 'csrf',
|
|
68
68
|
phase: 'request',
|
|
69
|
-
why: 'after auth so it
|
|
69
|
+
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
70
|
},
|
|
71
71
|
{
|
|
72
72
|
name: 'body',
|
|
@@ -188,8 +188,23 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
188
188
|
// own; this race is the half that answers the SOCKET when it does not. `work` keeps its
|
|
189
189
|
// own handler either way — a rejection arriving after the deadline already won is still
|
|
190
190
|
// a rejection, and an unhandled one takes the process down.
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
let settled = false;
|
|
192
|
+
const settle = (): void => {
|
|
193
|
+
settled = true;
|
|
194
|
+
};
|
|
195
|
+
void work.then(settle, settle);
|
|
196
|
+
try {
|
|
197
|
+
await Promise.race([work, deadline.expired]);
|
|
198
|
+
} finally {
|
|
199
|
+
// The deadline won and the handler is still running: it holds its pool slot and
|
|
200
|
+
// whatever it was writing. The caller's own count ends when this response leaves, so
|
|
201
|
+
// the orphan is counted in its own right until it settles — a drain that saw an idle
|
|
202
|
+
// process here closed the pool under it.
|
|
203
|
+
if (!settled) {
|
|
204
|
+
const done = beginWork();
|
|
205
|
+
void work.then(done, done);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
193
208
|
}
|
|
194
209
|
} catch (error) {
|
|
195
210
|
ctx.error = error;
|
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);
|
|
@@ -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);
|
|
@@ -1,26 +1,13 @@
|
|
|
1
|
-
// The shared rate-limit store: one Postgres table, one `insert … on conflict` per take
|
|
2
|
-
// replicas count against one bucket. Without it `
|
|
3
|
-
// declaration nothing can satisfy while `docker/helm/values.yaml` runs `
|
|
1
|
+
// The shared rate-limit store: one Postgres table, one `insert … on conflict` per take and a plain
|
|
2
|
+
// `select` per peek, so N replicas count against one bucket. Without it `rateLimit.scope: 'shared'`
|
|
3
|
+
// is a declaration nothing can satisfy while `docker/helm/values.yaml` runs `replicas: 3`.
|
|
4
4
|
// Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
5
5
|
|
|
6
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
7
|
import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
|
|
7
8
|
import { rateLimitDecision } from './rate-limit';
|
|
8
9
|
import { rateLimitStoreUnavailable } from './rate-limit-errors';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The one thing this store needs from the DB layer, declared structurally rather than imported —
|
|
12
|
-
* `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/jobs` declare the same shape for
|
|
13
|
-
* the same reason: neither package owns the other's connection. Here it is also the only option:
|
|
14
|
-
* `@ultimat3/http` has no `@ultimat3/db` dependency at all, and taking one to type a single method
|
|
15
|
-
* would put the whole database package in this package's install graph.
|
|
16
|
-
*
|
|
17
|
-
* **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template whose
|
|
18
|
-
* positional form is `unsafe`. What satisfies it is a client that already speaks `(text, values)`,
|
|
19
|
-
* wrapped in one line — `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own.
|
|
20
|
-
*/
|
|
21
|
-
export interface PgExecutor {
|
|
22
|
-
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
23
|
-
}
|
|
10
|
+
import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
24
11
|
|
|
25
12
|
/**
|
|
26
13
|
* Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
|
|
@@ -93,6 +80,13 @@ returning tokens, spent
|
|
|
93
80
|
|
|
94
81
|
export const SQL_RATE_LIMIT_RESET = 'delete from x_rate_limit where key = $1';
|
|
95
82
|
|
|
83
|
+
/**
|
|
84
|
+
* `peek`: a plain read, never the upsert — it runs on every request to a required route, and a
|
|
85
|
+
* write there was a row lock and a WAL record per signed-in page. The refill is computed in JS
|
|
86
|
+
* from the row by `refilledTokens`, the memory store's own arithmetic; no row means full.
|
|
87
|
+
*/
|
|
88
|
+
export const SQL_RATE_LIMIT_PEEK = 'select tokens, last_ms from x_rate_limit where key = $1';
|
|
89
|
+
|
|
96
90
|
/**
|
|
97
91
|
* The memory store's forget rule, in SQL: a bucket back at capacity answers exactly as a missing
|
|
98
92
|
* one, so dropping it changes no decision. A bucket that never refills
|
|
@@ -114,6 +108,12 @@ delete from x_rate_limit
|
|
|
114
108
|
* refill_per_second >= capacity
|
|
115
109
|
`;
|
|
116
110
|
|
|
111
|
+
interface PeekRow {
|
|
112
|
+
/** `double precision` / `bigint`, which some clients hand back as strings. */
|
|
113
|
+
readonly tokens: number | string;
|
|
114
|
+
readonly last_ms: number | string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
117
|
interface TakeRow {
|
|
118
118
|
/** `double precision`, which some clients hand back as a string. */
|
|
119
119
|
readonly tokens: number | string;
|
|
@@ -184,6 +184,15 @@ export function postgresRateLimitStore(
|
|
|
184
184
|
return rateLimitDecision(bucket, Number(row.tokens), cost, isTrue(row.spent), nowMs);
|
|
185
185
|
},
|
|
186
186
|
|
|
187
|
+
async peek(key, bucket, nowMs) {
|
|
188
|
+
const row = (await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]))[0];
|
|
189
|
+
const tokens =
|
|
190
|
+
row === undefined
|
|
191
|
+
? bucket.capacity
|
|
192
|
+
: refilledTokens(bucket, Number(row.tokens), Number(row.last_ms), Math.floor(nowMs));
|
|
193
|
+
return rateLimitPeek(bucket, tokens);
|
|
194
|
+
},
|
|
195
|
+
|
|
187
196
|
async reset(key): Promise<void> {
|
|
188
197
|
await exec.query(SQL_RATE_LIMIT_RESET, [key]);
|
|
189
198
|
},
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What the pipeline SPENDS, and when: the `rate-limit` stage's list of keys for a request that
|
|
2
|
+
// has a caller, and the one allowance the `auth` stage spends for a request that failed to have
|
|
3
|
+
// one. Split out of `stages.ts` at the size ceiling; the bucket maths stays in `rate-limit.ts`.
|
|
4
|
+
|
|
5
|
+
import { actorView, type RequestContext } from './context';
|
|
6
|
+
import {
|
|
7
|
+
type RateLimitConfig,
|
|
8
|
+
type RateLimitDecision,
|
|
9
|
+
type RateLimiter,
|
|
10
|
+
type RateLimitSpend,
|
|
11
|
+
rateLimitSpends,
|
|
12
|
+
} from './rate-limit';
|
|
13
|
+
import { rateLimited } from './rate-limit-errors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The namespace failed credentials are counted in. Not route-scoped, for `TENANT_SCOPE`'s reason:
|
|
17
|
+
* a per-route key would hand one address its whole allowance again on every required route.
|
|
18
|
+
*/
|
|
19
|
+
export const UNAUTHENTICATED_SCOPE = 'unauthenticated';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spends each key in order and stops at the first refusal, so a caller its own bucket already
|
|
23
|
+
* refused costs its tenant nothing. Recorded on the context BEFORE the throw, so the 429 carries
|
|
24
|
+
* `Retry-After` and the `RateLimit-*` headers rather than making the client guess.
|
|
25
|
+
*/
|
|
26
|
+
const spendAll = async (
|
|
27
|
+
ctx: RequestContext,
|
|
28
|
+
limiter: RateLimiter,
|
|
29
|
+
spends: readonly RateLimitSpend[],
|
|
30
|
+
): Promise<void> => {
|
|
31
|
+
let answer: RateLimitDecision | undefined;
|
|
32
|
+
let refusedKey: string | undefined;
|
|
33
|
+
for (const spend of spends) {
|
|
34
|
+
const decision = await limiter.check(spend.key, spend.bucket);
|
|
35
|
+
// The bucket closest to refusing is the one the caller has to plan against: reporting
|
|
36
|
+
// `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that tells a
|
|
37
|
+
// client it may proceed and then refuses its next call.
|
|
38
|
+
if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
|
|
39
|
+
if (!decision.allowed) {
|
|
40
|
+
answer = decision;
|
|
41
|
+
refusedKey = spend.key;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (answer === undefined) return;
|
|
46
|
+
ctx.rateLimit = answer;
|
|
47
|
+
for (const [name, value] of Object.entries(limiter.headers(answer))) {
|
|
48
|
+
ctx.headers.set(name, value);
|
|
49
|
+
}
|
|
50
|
+
if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `rate-limit` stage. A LIST, and the second entry is why: the key builder used to pick ONE
|
|
55
|
+
* subject — actor > org > ip, exclusive — so an authenticated request never touched a tenant
|
|
56
|
+
* bucket and one org's 8,000 seats each ran their own allowance against one shared pool.
|
|
57
|
+
*/
|
|
58
|
+
export const spendRequestBuckets = async (
|
|
59
|
+
ctx: RequestContext,
|
|
60
|
+
limiter: RateLimiter,
|
|
61
|
+
config: RateLimitConfig,
|
|
62
|
+
routeName: string,
|
|
63
|
+
): Promise<void> => {
|
|
64
|
+
if (!config.enabled) return;
|
|
65
|
+
const actor = actorView(ctx.actor);
|
|
66
|
+
await spendAll(
|
|
67
|
+
ctx,
|
|
68
|
+
limiter,
|
|
69
|
+
rateLimitSpends(
|
|
70
|
+
{ actorId: actor?.id ?? null, orgId: actor?.orgId ?? null, ip: ctx.ip, routeName },
|
|
71
|
+
{
|
|
72
|
+
route: ctx.route?.meta.rateLimit ?? config.defaultBucket,
|
|
73
|
+
tenant: config.tenantBucket,
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The one key failed credentials are counted under: the address alone, never the route. */
|
|
80
|
+
const unauthenticatedKey = (ip: string | null): string =>
|
|
81
|
+
`${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `auth` stage's GATE, before `authenticate()` on a required route: an address whose failure
|
|
85
|
+
* allowance is spent is refused here, a valid credential included. Spending only on failure
|
|
86
|
+
* answered a wrong guess 429 and still served a right one, so it bounded nothing a guesser cares
|
|
87
|
+
* about — and every refused guess was still a credential-store read.
|
|
88
|
+
*
|
|
89
|
+
* READ ONLY: `RateLimitStore.peek`, never a take — this runs on every request to a required route,
|
|
90
|
+
* signed-in ones included, and a zero-cost take was a Postgres upsert on each. The refusal carries
|
|
91
|
+
* the peek's own `Retry-After` (`rateLimitPeek`). With no address there is no key
|
|
92
|
+
* worth refusing on — `ip:unknown` is everyone at once — so the gate stands aside and the failure
|
|
93
|
+
* path below still counts.
|
|
94
|
+
*/
|
|
95
|
+
export const refuseExhaustedAddress = async (
|
|
96
|
+
ctx: RequestContext,
|
|
97
|
+
limiter: RateLimiter,
|
|
98
|
+
config: RateLimitConfig,
|
|
99
|
+
): Promise<void> => {
|
|
100
|
+
if (!config.enabled || ctx.ip === null) return;
|
|
101
|
+
const key = unauthenticatedKey(ctx.ip);
|
|
102
|
+
const peek = await limiter.peek(key, config.defaultBucket);
|
|
103
|
+
if (peek.remaining >= 1) return;
|
|
104
|
+
throw rateLimited(key, peek.retryAfterSeconds);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `auth` stage's failure path. A request refused 401 never reaches `rate-limit`, so nothing
|
|
109
|
+
* metered it: a caller with no credential, or a wrong one, could ask the credential store about
|
|
110
|
+
* tokens without bound. Spent only on FAILURE and keyed on the address ALONE — a signed-in
|
|
111
|
+
* caller behind the same NAT spends nothing here. Once it is spent, `refuseExhaustedAddress`
|
|
112
|
+
* refuses that address on required routes, signed-in callers included, until it refills.
|
|
113
|
+
*/
|
|
114
|
+
export const spendUnauthenticated = (
|
|
115
|
+
ctx: RequestContext,
|
|
116
|
+
limiter: RateLimiter,
|
|
117
|
+
config: RateLimitConfig,
|
|
118
|
+
): Promise<void> => {
|
|
119
|
+
if (!config.enabled) return Promise.resolve();
|
|
120
|
+
return spendAll(ctx, limiter, [
|
|
121
|
+
{ key: unauthenticatedKey(ctx.ip), bucket: config.defaultBucket },
|
|
122
|
+
]);
|
|
123
|
+
};
|