@ultimat3/http 30.9.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/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, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
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
- ErrorPageAction,
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
- clientAddress,
110
- forwardedElement,
111
- } from './forwarded';
112
- export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
113
- export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
114
- export {
115
- BUN_MAX_IDLE_TIMEOUT_S,
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 { OverlayMeta, OverlayNotice } from './overlay';
141
- export { overlayResponse, wantsOverlay } from './overlay';
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
- rateLimitBucketConflict,
175
- rateLimitBucketUnbound,
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 type { AdoptRateLimitStoreOptions } from './rate-limit-installed';
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
- postgresRateLimitStore,
198
- SQL_RATE_LIMIT_TABLE,
199
- } from './rate-limit-postgres';
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
- DEFAULT_WEBHOOK_BODY_LIMIT,
263
- DEFAULT_WEBHOOK_TOLERANCE_MS,
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: 'x_timezone',
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 { beginWork, recordRequest, runWithContext, withSpan } from '@ultimat3/core';
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({ rateLimitStore })` is the one supported way, and
119
- * it hands over a limiter built from the SAME merged config this constructor would have built.
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
- // four are what a reader joins on — `x-request-id` off the response, the status the
304
- // client saw, and the method/path split out of the span name.
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
- 'http.request_id': ctx.requestId,
307
- 'http.method': ctx.method,
308
- // The PATTERN (`/r/:token`), the OpenTelemetry meaning of `http.route` — the
309
- // concrete path leaked every token a URL carries into the trace store.
310
- 'http.route': ctx.route?.path ?? UNMATCHED_ROUTE,
311
- 'http.status_code': response.status,
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
  {
@@ -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` is refused by name
45
- * because it already has a top-level home (`ProblemDocument.issues`, parsed and bounded on its
46
- * own terms), and a second copy under `meta.issues` is two places for a client to read one fact.
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') return '`issues` already rides at the top level of the document';
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[name];
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) buckets[name] = entry.bucket;
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?.[name];
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
- export function degradedFallback(): DegradedFallback {
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
- 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',
94
+ ...degradedLine(owner),
80
95
  });
81
96
  },
82
97
  };
@@ -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
- ? 'installRateLimitStore(rateLimitStore) at boot, with the same postgresRateLimitStore({ executor }) passed to httpServer({ routes, rateLimitStore })'
39
- : "httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
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: 'pass the STORE and let the pipeline build the limiter — httpServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
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 httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
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
  /**