@ultimat3/http 30.8.0 → 31.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 +44 -40
- package/README.md +48 -35
- package/package.json +7 -6
- package/src/adapter.ts +14 -0
- package/src/auth-redirect.ts +11 -3
- package/src/bearer-mount.ts +7 -5
- package/src/body-ceiling.ts +54 -0
- package/src/byte-range.ts +6 -2
- package/src/compress.ts +77 -6
- package/src/config.ts +29 -7
- package/src/context.ts +3 -2
- package/src/cors.ts +20 -0
- package/src/error-facts.ts +27 -8
- package/src/error-log-level.ts +16 -2
- package/src/error-map-http.ts +2 -0
- package/src/error-map-tier-1.ts +11 -3
- package/src/error-map-tier-2.ts +10 -0
- package/src/error-map-tier-3.ts +14 -0
- package/src/error-map-tier-4.ts +15 -0
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +9 -5
- package/src/hooks.ts +44 -1
- package/src/index.ts +31 -86
- package/src/locale.ts +2 -2
- package/src/pipeline.ts +40 -11
- package/src/problem-meta.ts +8 -5
- package/src/rate-limit-buckets.ts +21 -3
- package/src/rate-limit-config.ts +123 -0
- package/src/rate-limit-degrade.ts +19 -4
- package/src/rate-limit-errors.ts +4 -4
- package/src/rate-limit-installed.ts +43 -53
- package/src/rate-limit-postgres.ts +10 -9
- package/src/rate-limit-redis.ts +176 -0
- package/src/rate-limit-stage.ts +3 -2
- package/src/rate-limit-store-deadline.ts +24 -6
- package/src/rate-limit.ts +13 -108
- package/src/server.ts +19 -37
- package/src/stages.ts +5 -2
package/src/index.ts
CHANGED
|
@@ -41,35 +41,17 @@ export {
|
|
|
41
41
|
useRequestHeaders,
|
|
42
42
|
withAnonymousRequest,
|
|
43
43
|
} from './context';
|
|
44
|
-
export type { InboundCorrelation } from './correlation';
|
|
45
44
|
export type { CorsConfig } from './cors';
|
|
46
|
-
export { allowedOrigin,
|
|
45
|
+
export { allowedOrigin, DEFAULT_CORS, originListed, preflight } from './cors';
|
|
47
46
|
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
48
47
|
export { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
|
|
49
48
|
export type { Deadline } from './deadline';
|
|
50
49
|
export { resolveTimeoutMs, startDeadline } from './deadline';
|
|
51
50
|
export type { ErrorFacts, ProblemDocument } from './error-facts';
|
|
52
|
-
export {
|
|
53
|
-
factsOf,
|
|
54
|
-
problemTypeFor,
|
|
55
|
-
renderErrorLines,
|
|
56
|
-
retryAfterOf,
|
|
57
|
-
toProblem,
|
|
58
|
-
} from './error-facts';
|
|
51
|
+
export { factsOf, problemTypeFor, renderErrorLines, retryAfterOf, toProblem } from './error-facts';
|
|
59
52
|
export { ERROR_STATUS } from './error-map';
|
|
60
|
-
export type {
|
|
61
|
-
|
|
62
|
-
ErrorPageCopy,
|
|
63
|
-
ErrorPageGroup,
|
|
64
|
-
ErrorPageInput,
|
|
65
|
-
ErrorPageOptions,
|
|
66
|
-
} from './error-page';
|
|
67
|
-
export {
|
|
68
|
-
ERROR_PAGE_LINKS,
|
|
69
|
-
errorPageResponse,
|
|
70
|
-
renderErrorPage,
|
|
71
|
-
resolveErrorPageCopy,
|
|
72
|
-
} from './error-page';
|
|
53
|
+
export type { ErrorPageInput } from './error-page';
|
|
54
|
+
export { ERROR_PAGE_LINKS, renderErrorPage } from './error-page';
|
|
73
55
|
export {
|
|
74
56
|
DEFAULT_STATUS,
|
|
75
57
|
declaredStatusFor,
|
|
@@ -79,11 +61,9 @@ export {
|
|
|
79
61
|
} from './error-status';
|
|
80
62
|
export type { HttpErrorCode } from './errors';
|
|
81
63
|
export {
|
|
82
|
-
bearerMountInvalid,
|
|
83
64
|
bodyInvalid,
|
|
84
65
|
buildSkew,
|
|
85
66
|
draining,
|
|
86
|
-
errorStatusInvalid,
|
|
87
67
|
finalizeFailed,
|
|
88
68
|
forbidden,
|
|
89
69
|
HTTP_ERROR_CODES,
|
|
@@ -92,30 +72,21 @@ export {
|
|
|
92
72
|
methodNotAllowed,
|
|
93
73
|
noRequest,
|
|
94
74
|
overloaded,
|
|
95
|
-
pathInvalid,
|
|
96
|
-
pipelineNoResponse,
|
|
97
75
|
requestTimedOut,
|
|
98
|
-
routeCacheInvalid,
|
|
99
|
-
routeConflict,
|
|
100
76
|
routeNotFound,
|
|
101
|
-
serverNotStarted,
|
|
102
|
-
trustProxyUnset,
|
|
103
77
|
unauthenticated,
|
|
104
|
-
webhookSignatureInvalid,
|
|
105
|
-
webhookSignatureStale,
|
|
106
78
|
} from './errors';
|
|
107
79
|
export type { ForwardedInput, ForwardedSplit } from './forwarded';
|
|
80
|
+
export { clientAddress, forwardedElement } from './forwarded';
|
|
81
|
+
export type { Authenticator, AuthenticatorOptions, AuthzDecision, ServerHooks } from './hooks';
|
|
108
82
|
export {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
IDLE_TIMEOUT_MARGIN_S,
|
|
117
|
-
socketIdleTimeoutSeconds,
|
|
118
|
-
} from './idle-timeout';
|
|
83
|
+
configureAuthenticator,
|
|
84
|
+
configuredAuthenticator,
|
|
85
|
+
configuredCredentialTtlMs,
|
|
86
|
+
MAX_CREDENTIAL_TTL_MS,
|
|
87
|
+
resetAuthenticator,
|
|
88
|
+
} from './hooks';
|
|
89
|
+
export { socketIdleTimeoutSeconds } from './idle-timeout';
|
|
119
90
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
120
91
|
export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG } from './locale';
|
|
121
92
|
export type { RequestAddress } from './locale-prefix';
|
|
@@ -137,13 +108,12 @@ export type {
|
|
|
137
108
|
ServableObject,
|
|
138
109
|
} from './object-response';
|
|
139
110
|
export { notModified, objectResponse } from './object-response';
|
|
140
|
-
export type {
|
|
141
|
-
export {
|
|
111
|
+
export type { OverlayNotice } from './overlay';
|
|
112
|
+
export { wantsOverlay } from './overlay';
|
|
142
113
|
export { OVERLAY_STYLE } from './overlay-style';
|
|
143
114
|
export type { PeerIdentity } from './peer-identity';
|
|
144
|
-
export { peerIdentity } from './peer-identity';
|
|
145
115
|
export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
|
|
146
|
-
export { httpPipeline, PIPELINE_STAGES } from './pipeline';
|
|
116
|
+
export { httpPipeline, PIPELINE_STAGES, REQUEST_ID_ATTRIBUTE } from './pipeline';
|
|
147
117
|
export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
|
|
148
118
|
export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
|
|
149
119
|
export type {
|
|
@@ -151,7 +121,6 @@ export type {
|
|
|
151
121
|
MemoryRateLimitStore,
|
|
152
122
|
RateLimitConfig,
|
|
153
123
|
RateLimitDecision,
|
|
154
|
-
RateLimitDeclaration,
|
|
155
124
|
RateLimiter,
|
|
156
125
|
RateLimitKeyParts,
|
|
157
126
|
RateLimitScope,
|
|
@@ -161,42 +130,27 @@ export type {
|
|
|
161
130
|
export {
|
|
162
131
|
assertRateLimitScope,
|
|
163
132
|
DEFAULT_MAX_RATE_LIMIT_KEYS,
|
|
164
|
-
DEFAULT_RATE_LIMIT,
|
|
165
133
|
memoryRateLimitStore,
|
|
166
134
|
rateLimitDecision,
|
|
167
135
|
rateLimiter,
|
|
168
136
|
rateLimitSpends,
|
|
169
|
-
resolveRateLimitConfig,
|
|
170
|
-
toBucket,
|
|
171
137
|
} from './rate-limit';
|
|
172
138
|
export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
173
|
-
export {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
rateLimited,
|
|
177
|
-
rateLimitInvalid,
|
|
178
|
-
rateLimitNotShared,
|
|
179
|
-
rateLimitScopeUnset,
|
|
180
|
-
rateLimitStoreUnavailable,
|
|
181
|
-
tenantBucketUnknown,
|
|
182
|
-
} from './rate-limit-errors';
|
|
139
|
+
export type { RateLimitDeclaration } from './rate-limit-config';
|
|
140
|
+
export { DEFAULT_RATE_LIMIT, resolveRateLimitConfig, toBucket } from './rate-limit-config';
|
|
141
|
+
export { rateLimited, rateLimitInvalid } from './rate-limit-errors';
|
|
183
142
|
export { publishRateLimit, rateLimitHeaders } from './rate-limit-headers';
|
|
184
|
-
export
|
|
185
|
-
export {
|
|
186
|
-
adoptRateLimitStore,
|
|
187
|
-
assertInstalledRateLimitScope,
|
|
188
|
-
installedRateLimitStore,
|
|
189
|
-
installRateLimitStore,
|
|
190
|
-
resetRateLimitStore,
|
|
191
|
-
withScratchRateLimitStore,
|
|
192
|
-
} from './rate-limit-installed';
|
|
143
|
+
export { assertInstalledRateLimitScope, installedRateLimitStore } from './rate-limit-installed';
|
|
193
144
|
export type { RateLimitPeek } from './rate-limit-peek';
|
|
194
145
|
export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
|
|
195
146
|
export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
|
|
196
|
-
export {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
147
|
+
export { postgresRateLimitStore, SQL_RATE_LIMIT_TABLE } from './rate-limit-postgres';
|
|
148
|
+
export type {
|
|
149
|
+
RedisRateLimitClient,
|
|
150
|
+
RedisRateLimitStore,
|
|
151
|
+
RedisRateLimitStoreOptions,
|
|
152
|
+
} from './rate-limit-redis';
|
|
153
|
+
export { redisRateLimitStore } from './rate-limit-redis';
|
|
200
154
|
export { RATE_LIMIT_STORE_DEADLINE_MS } from './rate-limit-store-deadline';
|
|
201
155
|
export { setRedirect, takeRedirect } from './redirect';
|
|
202
156
|
export type { QueryValues } from './request';
|
|
@@ -212,7 +166,6 @@ export {
|
|
|
212
166
|
html,
|
|
213
167
|
jsonResponse,
|
|
214
168
|
NO_STORE,
|
|
215
|
-
noContent,
|
|
216
169
|
problem,
|
|
217
170
|
redirect,
|
|
218
171
|
stream,
|
|
@@ -231,12 +184,7 @@ export type {
|
|
|
231
184
|
RouteParams,
|
|
232
185
|
RouteTable,
|
|
233
186
|
} from './router';
|
|
234
|
-
export {
|
|
235
|
-
describeRoutes,
|
|
236
|
-
HTTP_METHODS,
|
|
237
|
-
httpRouter,
|
|
238
|
-
matchRoute,
|
|
239
|
-
} from './router';
|
|
187
|
+
export { describeRoutes, httpRouter, matchRoute } from './router';
|
|
240
188
|
export type { SecurityConfig } from './security-headers';
|
|
241
189
|
export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';
|
|
242
190
|
export type {
|
|
@@ -258,9 +206,6 @@ export { formatIssue, validateBody, validateSync } from './validate';
|
|
|
258
206
|
export type { BodyHmacOptions, VerifiedBody } from './webhook-hmac';
|
|
259
207
|
export { verifyBodyHmac } from './webhook-hmac';
|
|
260
208
|
export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
|
|
261
|
-
export {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
verifyWebhookSignature,
|
|
265
|
-
} from './webhook-verify';
|
|
266
|
-
export { type JoinedWebSocketMounts, joinWebSocketMounts } from './websocket-mounts';
|
|
209
|
+
export { DEFAULT_WEBHOOK_BODY_LIMIT, verifyWebhookSignature } from './webhook-verify';
|
|
210
|
+
export type { JoinedWebSocketMounts } from './websocket-mounts';
|
|
211
|
+
export { joinWebSocketMounts } from './websocket-mounts';
|
package/src/locale.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// lived here and all three disagreed with their owner about the same request.
|
|
5
5
|
|
|
6
6
|
import { LOCALE_COOKIE } from '@ultimat3/i18n';
|
|
7
|
-
import { TIMEZONE_DETECTED_COOKIE, TIMEZONE_HEADER } from '@ultimat3/time';
|
|
7
|
+
import { TIMEZONE_COOKIE, TIMEZONE_DETECTED_COOKIE, TIMEZONE_HEADER } from '@ultimat3/time';
|
|
8
8
|
|
|
9
9
|
export interface LocaleConfig {
|
|
10
10
|
/** Cookie the client sets when the user picks a locale explicitly. */
|
|
@@ -32,6 +32,6 @@ export const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
|
|
|
32
32
|
/** The default zone is `configureTime({ defaultZone })`'s, so a process has one answer, not two. */
|
|
33
33
|
export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
|
|
34
34
|
header: TIMEZONE_HEADER,
|
|
35
|
-
cookie:
|
|
35
|
+
cookie: TIMEZONE_COOKIE,
|
|
36
36
|
detectedCookie: TIMEZONE_DETECTED_COOKIE,
|
|
37
37
|
};
|
package/src/pipeline.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
beginWork,
|
|
8
|
+
instrument,
|
|
9
|
+
recordRequest,
|
|
10
|
+
runWithContext,
|
|
11
|
+
SEMCONV,
|
|
12
|
+
subscribed,
|
|
13
|
+
withSpan,
|
|
14
|
+
} from '@ultimat3/core';
|
|
7
15
|
import { defineHttpConfig, type HttpConfig } from './config';
|
|
8
16
|
import { asCtx, elapsedMs, type RequestContext, requestContext } from './context';
|
|
9
17
|
import { readCorrelation } from './correlation';
|
|
@@ -115,8 +123,8 @@ export interface PipelineDeps {
|
|
|
115
123
|
readonly hooks?: ServerHooks;
|
|
116
124
|
readonly middleware?: readonly Middleware[];
|
|
117
125
|
/**
|
|
118
|
-
* A limiter built elsewhere — `httpServer
|
|
119
|
-
*
|
|
126
|
+
* A limiter built elsewhere — `httpServer` is the one supported way, and it hands over a
|
|
127
|
+
* limiter over the INSTALLED store built from the SAME merged config this constructor would.
|
|
120
128
|
* One passed from anywhere else resolves bucket names against the table IT closed over, which
|
|
121
129
|
* is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
|
|
122
130
|
* a limiter that cannot enforce one (`X_RATE_LIMIT_BUCKET_UNBOUND`) rather than letting the
|
|
@@ -141,6 +149,9 @@ export interface Pipeline {
|
|
|
141
149
|
handle(request: Request, init: HandleInit): Promise<Response>;
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
/** The request span's id attribute: framework-specific, so `ultimate.*` — `x dev` joins on it. */
|
|
153
|
+
export const REQUEST_ID_ATTRIBUTE = 'ultimate.request_id';
|
|
154
|
+
|
|
144
155
|
export const httpPipeline = (deps: PipelineDeps): Pipeline => {
|
|
145
156
|
// Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
|
|
146
157
|
// bucket a route declares only becomes real at the construction that holds both. Idempotent, so
|
|
@@ -294,23 +305,29 @@ export const httpPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
294
305
|
// rejection nothing here predicted still counts, at the 500 such a request gets from
|
|
295
306
|
// the caller either way.
|
|
296
307
|
let status = 500;
|
|
308
|
+
let failure: { readonly error: unknown } | undefined;
|
|
297
309
|
try {
|
|
298
310
|
const response = await execute(request, ctx, deadline);
|
|
299
311
|
status = response.status;
|
|
300
312
|
span.updateName(routeSpanName(ctx.method, ctx.route?.path));
|
|
301
313
|
// The root span of every request carried no attributes at all, so an exporter got a
|
|
302
314
|
// name and a duration and nothing to correlate: which request, which outcome. These
|
|
303
|
-
//
|
|
304
|
-
//
|
|
315
|
+
// are what a reader joins on — `x-request-id` off the response, the status the client
|
|
316
|
+
// saw, and the method/route split out of the span name. OpenTelemetry's STABLE http server names (v31). `url.path` is deliberately absent:
|
|
317
|
+
// it is the concrete path, and a capability URL (`/r/<token>`) would put the token
|
|
318
|
+
// in every trace store this span is exported to — `http.route` carries the pattern.
|
|
305
319
|
span.setAttributes({
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
320
|
+
[REQUEST_ID_ATTRIBUTE]: ctx.requestId,
|
|
321
|
+
[SEMCONV.httpRequestMethod]: ctx.method,
|
|
322
|
+
[SEMCONV.httpRoute]: ctx.route?.path ?? UNMATCHED_ROUTE,
|
|
323
|
+
[SEMCONV.httpResponseStatusCode]: response.status,
|
|
324
|
+
[SEMCONV.urlScheme]: ctx.https ? 'https' : 'http',
|
|
325
|
+
[SEMCONV.serverAddress]: url.hostname,
|
|
312
326
|
});
|
|
313
327
|
return response;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
failure = { error };
|
|
330
|
+
throw error;
|
|
314
331
|
} finally {
|
|
315
332
|
// The route PATTERN (`/posts/:id`), exactly as the span above is named: a metric is
|
|
316
333
|
// a stored series per label set, and a concrete path is attacker-chosen and may
|
|
@@ -325,6 +342,18 @@ export const httpPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
325
342
|
// A live timer keeps the event loop from going idle, so a process that answered
|
|
326
343
|
// every request would still refuse to exit for the length of one timeout.
|
|
327
344
|
deadline.clear();
|
|
345
|
+
// The bus event, last so a throwing (strict) subscriber cannot leak the timer. Every
|
|
346
|
+
// request, sampled or not — the span reaches an exporter only when sampled. `route`
|
|
347
|
+
// is the PATTERN, or `undefined` when nothing matched.
|
|
348
|
+
if (subscribed('http.request')) {
|
|
349
|
+
instrument('http.request', {
|
|
350
|
+
method: ctx.method,
|
|
351
|
+
route: ctx.route?.path,
|
|
352
|
+
status,
|
|
353
|
+
durationMs: elapsedMs(ctx),
|
|
354
|
+
...failure,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
328
357
|
}
|
|
329
358
|
},
|
|
330
359
|
{
|
package/src/problem-meta.ts
CHANGED
|
@@ -41,13 +41,14 @@ export type ProblemMeta = Readonly<Record<string, ProblemMetaValue>>;
|
|
|
41
41
|
export const MAX_PROBLEM_META_BYTES = 4096;
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* A key is spelled the way a code's `meta` key is spelled in source. `issues`
|
|
45
|
-
* because
|
|
46
|
-
* own terms), and a second copy under `meta
|
|
44
|
+
* A key is spelled the way a code's `meta` key is spelled in source. `issues` and `issuesTruncated`
|
|
45
|
+
* are refused by name because each already has a top-level home (`ProblemDocument.issues`, parsed
|
|
46
|
+
* and bounded on its own terms, and the mark that qualifies it), and a second copy under `meta` is
|
|
47
|
+
* two places for a client to read one fact.
|
|
47
48
|
* `__proto__` is refused because `JSON.parse` on the receiving side mints it as a real own key.
|
|
48
49
|
*/
|
|
49
50
|
const KEY = /^[A-Za-z_$][\w$]*$/;
|
|
50
|
-
const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
|
|
51
|
+
const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', 'issuesTruncated', '__proto__']);
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* Why `key` may not ride a document, or `undefined` when it may. The one rule for both halves: an
|
|
@@ -56,7 +57,9 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
|
|
|
56
57
|
export function metaKeyRefusal(key: unknown): string | undefined {
|
|
57
58
|
if (typeof key !== 'string' || !KEY.test(key))
|
|
58
59
|
return `${renderCauseValue(key)} is not a meta key`;
|
|
59
|
-
if (key === 'issues'
|
|
60
|
+
if (key === 'issues' || key === 'issuesTruncated') {
|
|
61
|
+
return `\`${key}\` already rides at the top level of the document`;
|
|
62
|
+
}
|
|
60
63
|
if (RESERVED_KEYS.has(key)) return `\`${key}\` is not a key a document may carry`;
|
|
61
64
|
return undefined;
|
|
62
65
|
}
|
|
@@ -15,6 +15,17 @@ import type { Route } from './router';
|
|
|
15
15
|
const same = (a: Bucket, b: Bucket): boolean =>
|
|
16
16
|
a.capacity === b.capacity && a.refillPerSecond === b.refillPerSecond;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A bucket table is indexed by an author-written name, so it is read through `Object.hasOwn` and
|
|
20
|
+
* written through `defineProperty` — the same rule `rateLimiter`'s `declared` reads by. Plain
|
|
21
|
+
* `[name]` answers `Object` for 'constructor', and `[name] =` makes '__proto__' the prototype.
|
|
22
|
+
*/
|
|
23
|
+
const own = (
|
|
24
|
+
table: Readonly<Record<string, Bucket>> | undefined,
|
|
25
|
+
name: string,
|
|
26
|
+
): Bucket | undefined =>
|
|
27
|
+
table !== undefined && Object.hasOwn(table, name) ? table[name] : undefined;
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* The config with every route-declared bucket registered under the name that route selects.
|
|
20
31
|
*
|
|
@@ -55,7 +66,7 @@ export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]):
|
|
|
55
66
|
// Numbers with no name to file them under enforce nothing; `toRoute` always sets both.
|
|
56
67
|
if (bucket === undefined || name === undefined) continue;
|
|
57
68
|
const prior = declared.get(name);
|
|
58
|
-
const configured = config.rateLimit.buckets
|
|
69
|
+
const configured = own(config.rateLimit.buckets, name);
|
|
59
70
|
if (prior !== undefined && !same(prior.bucket, bucket)) {
|
|
60
71
|
throw rateLimitBucketConflict({
|
|
61
72
|
bucket: name,
|
|
@@ -78,7 +89,14 @@ export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]):
|
|
|
78
89
|
}
|
|
79
90
|
if (declared.size === 0) return config;
|
|
80
91
|
const buckets: Record<string, Bucket> = { ...config.rateLimit.buckets };
|
|
81
|
-
for (const [name, entry] of declared)
|
|
92
|
+
for (const [name, entry] of declared) {
|
|
93
|
+
Object.defineProperty(buckets, name, {
|
|
94
|
+
value: entry.bucket,
|
|
95
|
+
enumerable: true,
|
|
96
|
+
writable: true,
|
|
97
|
+
configurable: true,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
82
100
|
return { ...config, rateLimit: { ...config.rateLimit, buckets } };
|
|
83
101
|
};
|
|
84
102
|
|
|
@@ -98,7 +116,7 @@ export const assertRouteBuckets = (limiter: RateLimiter, routes: readonly Route[
|
|
|
98
116
|
const bucket = route.meta.rateLimitBucket;
|
|
99
117
|
const name = route.meta.rateLimit;
|
|
100
118
|
if (bucket === undefined || name === undefined) continue;
|
|
101
|
-
const found = limiter.buckets
|
|
119
|
+
const found = own(limiter.buckets, name);
|
|
102
120
|
if (found !== undefined && same(found, bucket)) continue;
|
|
103
121
|
throw rateLimitBucketUnbound({
|
|
104
122
|
bucket: name,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The rate limit as DECLARED: the shipped bucket table, the resolver `defineHttpConfig` runs over
|
|
2
|
+
// an app's half, and `toBucket`, the one conversion from `{ limit, windowMs }` to a `Bucket`. Every
|
|
3
|
+
// refusal is raised here, where an author can act on it, never on the first request.
|
|
4
|
+
import { httpCountInvalid } from './errors';
|
|
5
|
+
import type { Bucket, RateLimitConfig } from './rate-limit';
|
|
6
|
+
import { rateLimitInvalid, rateLimitScopeUnset, tenantBucketUnknown } from './rate-limit-errors';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Everything the framework can decide on its own. `scope` is NOT here: one process is the only
|
|
10
|
+
* thing a framework can promise without being told, and defaulting to it made "we did not ask"
|
|
11
|
+
* indistinguishable from "the app said one replica" — so the chart's `replicas: 3` enforced every
|
|
12
|
+
* number three times over, silently, and the boot check that exists for this
|
|
13
|
+
* (`assertRateLimitScope`) never fired because it only reads a `'shared'` declaration. The
|
|
14
|
+
* comment that used to sit here was right about the fact and wrong about the conclusion: ask.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
|
|
17
|
+
enabled: true,
|
|
18
|
+
defaultBucket: 'default',
|
|
19
|
+
tenantBucket: null,
|
|
20
|
+
buckets: {
|
|
21
|
+
default: { capacity: 120, refillPerSecond: 2 },
|
|
22
|
+
// Login/signup style endpoints: slow, no burst.
|
|
23
|
+
auth: { capacity: 10, refillPerSecond: 0.2 },
|
|
24
|
+
mutation: { capacity: 30, refillPerSecond: 1 },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `toBucket`'s rule, for a bucket written as numbers: at least one whole request held, and a
|
|
30
|
+
* finite refill above zero. A refill of 0 never forgets a key — on a shared store one entry held
|
|
31
|
+
* forever per caller, minted without bound by a caller rotating its IPv6 /64s.
|
|
32
|
+
*/
|
|
33
|
+
const assertBucket = (name: string, bucket: Bucket): void => {
|
|
34
|
+
if (!Number.isFinite(bucket.capacity) || bucket.capacity < 1) {
|
|
35
|
+
throw httpCountInvalid(
|
|
36
|
+
`rateLimit.buckets.${name}.capacity`,
|
|
37
|
+
bucket.capacity,
|
|
38
|
+
'a finite number of at least 1 request',
|
|
39
|
+
`rateLimit: { buckets: { ${name}: { capacity: 10, refillPerSecond: 0.2 } } }`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isFinite(bucket.refillPerSecond) || bucket.refillPerSecond <= 0) {
|
|
43
|
+
throw httpCountInvalid(
|
|
44
|
+
`rateLimit.buckets.${name}.refillPerSecond`,
|
|
45
|
+
bucket.refillPerSecond,
|
|
46
|
+
'a finite number of tokens per second above 0',
|
|
47
|
+
`rateLimit: { buckets: { ${name}: { capacity: ${String(bucket.capacity)}, refillPerSecond: 0.2 } } }`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `defineHttpConfig`'s one resolver for this slice, so the refusal happens where an author can
|
|
54
|
+
* act on it rather than at the first request. A limiter that is switched off has nothing to be
|
|
55
|
+
* wrong about, so `enabled: false` needs no declaration — and reads as `'process'`.
|
|
56
|
+
*/
|
|
57
|
+
export const resolveRateLimitConfig = (
|
|
58
|
+
input: Partial<RateLimitConfig> | undefined,
|
|
59
|
+
): RateLimitConfig => {
|
|
60
|
+
const merged = { ...DEFAULT_RATE_LIMIT, ...input };
|
|
61
|
+
for (const [name, bucket] of Object.entries(merged.buckets)) assertBucket(name, bucket);
|
|
62
|
+
// Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
|
|
63
|
+
// through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
|
|
64
|
+
// be the 120-burst read bucket — looser than what the author declared, and invisible.
|
|
65
|
+
if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
|
|
66
|
+
throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
|
|
67
|
+
}
|
|
68
|
+
if (input?.scope !== undefined) return { ...merged, scope: input.scope };
|
|
69
|
+
if (!merged.enabled) return { ...merged, scope: 'process' };
|
|
70
|
+
throw rateLimitScopeUnset();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** How a route, an action or a query spells a limit before it becomes a `Bucket`. */
|
|
74
|
+
export interface RateLimitDeclaration {
|
|
75
|
+
/** The burst a caller may spend at once. */
|
|
76
|
+
readonly limit: number;
|
|
77
|
+
/** The window that refills it. */
|
|
78
|
+
readonly windowMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `{ limit, windowMs }` as the limiter's own vocabulary: `5 / 600_000ms` is five held, one back
|
|
83
|
+
* every two minutes. The only conversion between a declaration and the enforcement, so the
|
|
84
|
+
* numbers an OpenAPI operation publishes and the numbers `withRouteBuckets` registers cannot
|
|
85
|
+
* drift. It lives HERE, beside `Bucket` and the maths, because `@ultimat3/action` and
|
|
86
|
+
* `@ultimat3/query` are the same tier and can never import each other — a copy in one of them is
|
|
87
|
+
* a second answer to "what does this limit mean" for the other.
|
|
88
|
+
*
|
|
89
|
+
* **The COMPUTED rate is validated, not just the two declared halves.** The division is where a
|
|
90
|
+
* pair that reads fine becomes one the limiter cannot run on, in both directions:
|
|
91
|
+
* `{ limit: Number.MAX_VALUE, windowMs: 1 }` computes to `Infinity` — a bucket that never empties,
|
|
92
|
+
* which is the same "declared a limit, enforced nothing" as `windowMs: 0` — and a tiny limit over
|
|
93
|
+
* a huge window underflows to `0`, a bucket that never refills, so the endpoint is closed after
|
|
94
|
+
* its first burst rather than limited.
|
|
95
|
+
*/
|
|
96
|
+
export const toBucket = (owner: string, declared: RateLimitDeclaration): Bucket => {
|
|
97
|
+
const refuse = (reason: string): never => {
|
|
98
|
+
throw rateLimitInvalid({
|
|
99
|
+
owner,
|
|
100
|
+
limit: declared.limit,
|
|
101
|
+
windowMs: declared.windowMs,
|
|
102
|
+
reason,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
// A capacity under one token cannot admit a single request, so the endpoint is closed, not
|
|
106
|
+
// limited — a policy's job, never a rate limit's.
|
|
107
|
+
if (!Number.isFinite(declared.limit) || declared.limit < 1) {
|
|
108
|
+
refuse('limit must be a finite number of at least 1 request');
|
|
109
|
+
}
|
|
110
|
+
if (!Number.isFinite(declared.windowMs) || declared.windowMs <= 0) {
|
|
111
|
+
refuse('windowMs must be finite and greater than zero');
|
|
112
|
+
}
|
|
113
|
+
const refillPerSecond = declared.limit / (declared.windowMs / 1000);
|
|
114
|
+
// Kept though the two checks above make it unreachable today: with `limit >= 1` and a finite
|
|
115
|
+
// window the smallest rate is ~5.6e-306, which is normal, not zero. It is the guard that has to
|
|
116
|
+
// move first if `limit >= 1` is ever relaxed.
|
|
117
|
+
if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
|
|
118
|
+
refuse(
|
|
119
|
+
`the refill rate it computes to is ${refillPerSecond} per second, which is a bucket that never empties — nothing would be enforced`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { capacity: declared.limit, refillPerSecond };
|
|
123
|
+
};
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { isUltimateError, logger } from '@ultimat3/core';
|
|
22
22
|
import type { RateLimitStore } from './rate-limit';
|
|
23
23
|
import { memoryRateLimitStore } from './rate-limit';
|
|
24
|
+
import type { SharedStoreOwner } from './rate-limit-store-deadline';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Codes `@ultimat3/db` raises when no connection could run the statement at all, and the store's
|
|
@@ -64,7 +65,23 @@ export interface DegradedFallback {
|
|
|
64
65
|
* decision. The warn is throttled on the CALLER's clock — the one every take is measured against —
|
|
65
66
|
* so a frozen test clock and a real one both get one line per window, never one per request.
|
|
66
67
|
*/
|
|
67
|
-
|
|
68
|
+
/** What each shared store's degraded line says is down, and the command that checks it. */
|
|
69
|
+
const degradedLine = (owner: SharedStoreOwner): { readonly cause: string; readonly fix: string } =>
|
|
70
|
+
owner === 'redisRateLimitStore'
|
|
71
|
+
? {
|
|
72
|
+
cause:
|
|
73
|
+
'the shared rate-limit store could not reach Redis, so this pod is counting limits in its own memory until it can — each limit is per pod, not per fleet, meanwhile',
|
|
74
|
+
fix: 'redis-cli -u "$REDIS_URL" PING # the client reconnects on its own once it answers PONG',
|
|
75
|
+
}
|
|
76
|
+
: {
|
|
77
|
+
cause:
|
|
78
|
+
'the shared rate-limit store could not reach the database, so this pod is counting limits in its own memory until it can — each limit is per pod, not per fleet, meanwhile',
|
|
79
|
+
fix: 'x doctor --json # names the database binding this pod cannot reach',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function degradedFallback(
|
|
83
|
+
owner: SharedStoreOwner = 'postgresRateLimitStore',
|
|
84
|
+
): DegradedFallback {
|
|
68
85
|
const store = memoryRateLimitStore();
|
|
69
86
|
let lastWarnMs: number | undefined;
|
|
70
87
|
return {
|
|
@@ -74,9 +91,7 @@ export function degradedFallback(): DegradedFallback {
|
|
|
74
91
|
lastWarnMs = nowMs;
|
|
75
92
|
logger.warn('http.rate_limit.degraded', {
|
|
76
93
|
code: isUltimateError(error) ? error.code : 'unknown',
|
|
77
|
-
|
|
78
|
-
'the shared rate-limit store could not reach the database, so this pod is counting limits in its own memory until it can — each limit is per pod, not per fleet, meanwhile',
|
|
79
|
-
fix: 'x doctor --json # names the database binding this pod cannot reach',
|
|
94
|
+
...degradedLine(owner),
|
|
80
95
|
});
|
|
81
96
|
},
|
|
82
97
|
};
|
package/src/rate-limit-errors.ts
CHANGED
|
@@ -35,8 +35,8 @@ export const rateLimitNotShared = (found: 'process' | 'disabled' | 'installed'):
|
|
|
35
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
36
|
fix:
|
|
37
37
|
found === 'installed'
|
|
38
|
-
? '
|
|
39
|
-
: "
|
|
38
|
+
? "defineHttpConfig({ rateLimit: { scope: 'process' } }) # to accept per-replica limits — or delete rateLimitStore from apps/<app>/runtime.ts so the boot installs postgresRateLimitStore over the pool it opened, or name a store whose scope is 'shared'"
|
|
39
|
+
: "export const runtime = { rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) } # apps/<app>/runtime.ts — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -135,7 +135,7 @@ export const rateLimitBucketUnbound = (input: {
|
|
|
135
135
|
? 'does not hold that bucket, so the route would run on the default one'
|
|
136
136
|
: `holds ${numbers(input.found)} for it`
|
|
137
137
|
}`,
|
|
138
|
-
fix: '
|
|
138
|
+
fix: 'httpServer({ routes }) # drop PipelineDeps.limiter: the server builds the limiter over the installed store (rateLimitStore in apps/<app>/runtime.ts), so the bucket table is the one the routes registered',
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
/**
|
|
@@ -151,7 +151,7 @@ export const rateLimitScopeUnset = (): HttpError =>
|
|
|
151
151
|
code: 'X_RATE_LIMIT_SCOPE_UNSET',
|
|
152
152
|
cause:
|
|
153
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',
|
|
154
|
-
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 rateLimitStore: postgresRateLimitStore({ executor }) in apps/<app>/runtime.ts 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",
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
/**
|