@ultimat3/http 22.15.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 +41 -15
- package/README.md +39 -9
- package/package.json +6 -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 -452
- 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/hooks.ts +12 -1
- 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 +38 -46
package/src/hooks.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// be imported here), and — a seam of a different kind, deciding nothing — what a dev diagnostic
|
|
4
4
|
// found. All three are declared structurally, which keeps the import boundary intact and keeps
|
|
5
5
|
// the pipeline testable.
|
|
6
|
-
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import type { Actor, UltimateError } from '@ultimat3/core';
|
|
7
7
|
import type { RequestContext } from './context';
|
|
8
8
|
import type { OverlayNotice } from './overlay';
|
|
9
9
|
import type { UltimateRequest } from './request';
|
|
@@ -51,6 +51,17 @@ export interface ServerHooks {
|
|
|
51
51
|
status: number,
|
|
52
52
|
ctx: RequestContext,
|
|
53
53
|
) => Promise<string | undefined> | string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Why nothing is routed at this path, when a higher tier knows more than "no route". Consulted
|
|
56
|
+
* ONLY on a miss — never for a matched route, a refused method or an unreadable path — and the
|
|
57
|
+
* error it returns is thrown in place of `X_ROUTE_NOT_FOUND`; `undefined` keeps the plain miss.
|
|
58
|
+
* `@ultimat3/action` answers here for a path that is one of its actions under a path style this
|
|
59
|
+
* server does not serve.
|
|
60
|
+
*
|
|
61
|
+
* Synchronous and given only the request line: a miss is reached by any anonymous caller, so
|
|
62
|
+
* what runs on it must not read a body, a session or a database.
|
|
63
|
+
*/
|
|
64
|
+
readonly explainMiss?: (method: string, pathname: string) => UltimateError | undefined;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export type Authenticator = NonNullable<ServerHooks['authenticate']>;
|
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
|
+
};
|
package/src/rate-limit.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
rateLimitScopeUnset,
|
|
12
12
|
tenantBucketUnknown,
|
|
13
13
|
} from './rate-limit-errors';
|
|
14
|
+
import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
|
|
@@ -36,6 +37,12 @@ export interface RateLimitStore {
|
|
|
36
37
|
/** Declared, never inferred: a driver knows where its counters live; nothing else does. */
|
|
37
38
|
readonly scope: RateLimitScope;
|
|
38
39
|
take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
|
|
40
|
+
/**
|
|
41
|
+
* What `key` holds NOW, refill applied — READ ONLY: no write, no row, no entry created. A key
|
|
42
|
+
* never taken is full. Required, because the `auth` stage asks it on every request to a required
|
|
43
|
+
* route, and a zero-cost `take` there was a Postgres upsert on every signed-in page.
|
|
44
|
+
*/
|
|
45
|
+
peek(key: string, bucket: Bucket, nowMs: number): Promise<RateLimitPeek>;
|
|
39
46
|
reset(key: string): Promise<void>;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -298,6 +305,14 @@ export const memoryRateLimitStore = (
|
|
|
298
305
|
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
299
306
|
return Promise.resolve(decision);
|
|
300
307
|
},
|
|
308
|
+
peek(key, bucket, nowMs) {
|
|
309
|
+
const state = buckets.get(key);
|
|
310
|
+
const tokens =
|
|
311
|
+
state === undefined
|
|
312
|
+
? bucket.capacity
|
|
313
|
+
: refilledTokens(bucket, state.tokens, state.lastMs, nowMs);
|
|
314
|
+
return Promise.resolve(rateLimitPeek(bucket, tokens));
|
|
315
|
+
},
|
|
301
316
|
reset(key) {
|
|
302
317
|
buckets.delete(key);
|
|
303
318
|
return Promise.resolve();
|
|
@@ -377,6 +392,8 @@ export interface RateLimiter {
|
|
|
377
392
|
*/
|
|
378
393
|
readonly buckets?: Readonly<Record<string, Bucket>> | undefined;
|
|
379
394
|
check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
|
|
395
|
+
/** The store's read-only `peek`, against the named bucket. Spends nothing, writes nothing. */
|
|
396
|
+
peek(key: string, bucketName: string): Promise<RateLimitPeek>;
|
|
380
397
|
headers(decision: RateLimitDecision): Record<string, string>;
|
|
381
398
|
/** Throws `X_RATE_LIMITED` when the bucket is empty. */
|
|
382
399
|
assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
|
|
@@ -420,6 +437,7 @@ export const createRateLimiter = (options: {
|
|
|
420
437
|
// no other way to learn what this limiter can enforce.
|
|
421
438
|
buckets: options.config.buckets,
|
|
422
439
|
check,
|
|
440
|
+
peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
|
|
423
441
|
headers: (decision) => ({
|
|
424
442
|
'ratelimit-limit': String(decision.limit),
|
|
425
443
|
'ratelimit-remaining': String(decision.remaining),
|