@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.
@@ -1,72 +1,62 @@
1
- // The store a PRIMITIVE's declared `rateLimit:` is counted in — `@ultimat3/action`'s `invoke` and
2
- // `@ultimat3/query`'s read path both spend from it, on every surface. One slot, here, so the two
3
- // tier-3 packages (which cannot import each other) count in one place, and the boot fills it with
4
- // the same instance it hands `httpServer({ rateLimitStore })`.
1
+ // The store a PRIMITIVE's declared `rateLimit:` is counted in — and, since 31.0.0, the one the
2
+ // HTTP pipeline's limiter counts in too. `@ultimat3/action`'s `invoke` and `@ultimat3/query`'s
3
+ // read path both spend from it, on every surface; one slot, here, so the two tier-3 packages
4
+ // (which cannot import each other) and every `httpServer` count in one place.
5
5
 
6
- import type { RateLimitConfig, RateLimitStore } from './rate-limit';
6
+ import { adapterSlot } from '@ultimat3/core/adapter';
7
+ import type { Bucket, RateLimitConfig, RateLimitStore } from './rate-limit';
7
8
  import { memoryRateLimitStore } from './rate-limit';
8
9
  import { rateLimitNotShared } from './rate-limit-errors';
9
10
 
10
- /** What a process falls back to when nothing chose: one process' memory. */
11
+ /** What a process falls back to when nothing installed one: one process' memory. */
11
12
  let base: RateLimitStore = memoryRateLimitStore();
12
- let baseChosen = false;
13
13
  /**
14
- * Adopted stores, newest last. A STACK of frames, each popped only by its own adopter: two
15
- * servers started A-then-B and stopped A-then-B used to have A's stop restore what A replaced —
16
- * reverting the slot under B while B still served. A frame object is its own identity, so the
17
- * same store adopted twice is two frames.
14
+ * Installed stores over the base: core's `adapterSlot`, a stack each release pops only its own
15
+ * frame from — two boots stopped in either order never revert the slot under the one still serving.
18
16
  */
19
- const frames: { readonly store: RateLimitStore }[] = [];
17
+ const slot = /* @__PURE__ */ adapterSlot<RateLimitStore>(() => base);
20
18
 
21
- const current = (): RateLimitStore => frames.at(-1)?.store ?? base;
22
- const isChosen = (): boolean => baseChosen || frames.length > 0;
19
+ const current = (): RateLimitStore => slot.current();
23
20
 
24
21
  /**
25
- * The embedder's one-line install, for a process that builds its own boot: the base everything
26
- * else stacks over. Left at the default it is one process' memory — right for dev and tests, and
27
- * N × every declared limit behind N replicas, which `assertInstalledRateLimitScope` refuses under
28
- * a `'shared'` declaration. A boot that has to give the slot back uses `adoptRateLimitStore`.
22
+ * The boot's install, and the only one: exported from `@ultimat3/http/adapter`, never from the
23
+ * package's `.` barrel. An app does not call it — it names its store as `rateLimitStore` in
24
+ * `apps/<app>/runtime.ts` and the boot installs that, or `postgresRateLimitStore` over the pool it
25
+ * opened. Answers the undo that removes THIS frame and no other, whatever order installers stop in.
29
26
  */
30
- export const installRateLimitStore = (store: RateLimitStore): void => {
31
- base = store;
32
- baseChosen = true;
33
- };
27
+ export const installRateLimitStore = (store: RateLimitStore): (() => void) => slot.install(store);
34
28
 
35
29
  export const installedRateLimitStore = (): RateLimitStore => current();
36
30
 
37
- /** Test-only. Every frame and the base, back to one process' memory. */
38
- export const resetRateLimitStore = (): void => {
39
- frames.length = 0;
40
- base = memoryRateLimitStore();
41
- baseChosen = false;
42
- };
43
-
44
- export interface AdoptRateLimitStoreOptions {
45
- /**
46
- * `httpServer`'s rule: never over a store something already CHOSE. The boot installs its own
47
- * before any server exists, and a server handed a different one must not move every action's
48
- * counters out from under it — the chosen one stands, and `assertInstalledRateLimitScope` still
49
- * holds it to the declaration.
50
- */
51
- readonly keepChosen?: boolean;
52
- }
31
+ /**
32
+ * The slot as a store: every call reads whatever is installed AT THAT CALL. What `httpServer`'s
33
+ * limiter counts in, so a store installed after the server was built — a test's override seam, a
34
+ * boot that orders its installs differently — is the one a request spends from. Its `scope` is
35
+ * read live too; the boot check reads it once, when the pipeline is built.
36
+ */
37
+ export const liveRateLimitStore: RateLimitStore = Object.freeze({
38
+ get scope() {
39
+ return current().scope;
40
+ },
41
+ take: (key: string, bucket: Bucket, cost: number, nowMs: number) =>
42
+ current().take(key, bucket, cost, nowMs),
43
+ peek: (key: string, bucket: Bucket, nowMs: number) => current().peek(key, bucket, nowMs),
44
+ reset: (key: string) => current().reset(key),
45
+ });
53
46
 
54
47
  /**
55
- * Push `store` as the one primitives spend from, and answer the undo that removes THIS frame and
56
- * no other — whatever order the adopters stop in. The boot (`startServices`, `startRoles`) and
57
- * `httpServer` all go through it, so every install has a matching, order-proof release.
48
+ * The per-test reset: the DEFAULT memory store's buckets emptied, every installed store left where
49
+ * it is. A suite that installed a shared store once (a live Redis or Postgres run) keeps running on
50
+ * it; only the fallback a plain unit test counts in forgets what the previous test spent.
58
51
  */
59
- export const adoptRateLimitStore = (
60
- store: RateLimitStore,
61
- options: AdoptRateLimitStoreOptions = {},
62
- ): (() => void) => {
63
- if (options.keepChosen === true && isChosen() && current() !== store) return () => undefined;
64
- const frame = { store };
65
- frames.push(frame);
66
- return () => {
67
- const at = frames.indexOf(frame);
68
- if (at !== -1) frames.splice(at, 1);
69
- };
52
+ export const clearDefaultRateLimitBuckets = (): void => {
53
+ base = memoryRateLimitStore();
54
+ };
55
+
56
+ /** Test-only. Every frame and the base, back to one process' memory. */
57
+ export const resetRateLimitStore = (): void => {
58
+ slot.reset();
59
+ base = memoryRateLimitStore();
70
60
  };
71
61
 
72
62
  /**
@@ -86,7 +76,7 @@ export const assertInstalledRateLimitScope = (config: RateLimitConfig): void =>
86
76
  */
87
77
  export async function withScratchRateLimitStore<T>(fn: () => Promise<T>): Promise<T> {
88
78
  const scratch = memoryRateLimitStore();
89
- const release = adoptRateLimitStore({
79
+ const release = installRateLimitStore({
90
80
  scope: current().scope,
91
81
  take: (key, bucket, cost, nowMs) => scratch.take(key, bucket, cost, nowMs),
92
82
  peek: (key, bucket, nowMs) => scratch.peek(key, bucket, nowMs),
@@ -31,7 +31,10 @@ create table if not exists x_rate_limit (
31
31
  updated_at timestamptz not null default now()
32
32
  );
33
33
 
34
- create index if not exists x_rate_limit_updated_at_idx on x_rate_limit (updated_at);
34
+ -- v31: never scanned (the purge reads last_ms) and paid on every take. Dropped where an earlier
35
+ -- boot created it, idempotent like the rest. Never a semicolon in a comment here: the boot splits
36
+ -- this text on it.
37
+ drop index if exists x_rate_limit_updated_at_idx;
35
38
  `;
36
39
 
37
40
  /**
@@ -140,7 +143,7 @@ export interface PostgresRateLimitStore extends RateLimitStore {
140
143
  /**
141
144
  * Delete every bucket that has refilled to capacity, and answer how many. The table is the one
142
145
  * part of this store that does not bound itself — Postgres forgets nothing on its own, and the
143
- * key falls back to the connection address, so a scan rotating through an IPv6 /64 mints a row
146
+ * key falls back to the connection address, so a scan rotating through IPv6 /64s mints a row
144
147
  * per request. An app runs this from a `task` on whatever cadence its traffic deserves.
145
148
  *
146
149
  * `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
@@ -152,19 +155,17 @@ export interface PostgresRateLimitStore extends RateLimitStore {
152
155
  }
153
156
 
154
157
  /**
155
- * **Install it at boot, beside the config that declares the scope.** The app owes two lines:
158
+ * **The boot installs it** over the pool it opened (`x dev`, `x serve`); an app names another
159
+ * store, or this one over another executor, in one place:
156
160
  *
157
161
  * ```ts
158
- * // app.config.ts — what this deployment REQUIRES
159
- * http: { rateLimit: { scope: 'shared' } }
160
- *
161
- * // apps/web/server.ts — what PROVIDES it
162
+ * // apps/web/runtime.ts — what PROVIDES it
162
163
  * const client = db();
163
- * httpServer({
164
+ * export const runtime = {
164
165
  * rateLimitStore: postgresRateLimitStore({
165
166
  * executor: { query: (text, values) => client.query({ text, values }) },
166
167
  * }),
167
- * });
168
+ * };
168
169
  * ```
169
170
  *
170
171
  * `assertRateLimitScope` compares the two once, inside `httpPipeline`, and a `'shared'`
@@ -0,0 +1,176 @@
1
+ // The shared rate-limit store over Redis: one Lua token bucket per take, so the refill, the spend
2
+ // and the expiry are one atomic step on the server. Beside Postgres, not instead of it: a hot key
3
+ // (one NAT, a tenant bucket) serialises every request on one row lock there, and Redis runs a
4
+ // script per key without one. The bucket expires once refilled, so it bounds itself — no purge.
5
+
6
+ import type { Scheduler } from '@ultimat3/core';
7
+ import { stringField } from '@ultimat3/core';
8
+ import { httpCountInvalid } from './errors';
9
+ import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
10
+ import { rateLimitDecision } from './rate-limit';
11
+ import { degradedFallback, isRateLimitStoreUnreachable } from './rate-limit-degrade';
12
+ import { rateLimitStoreUnavailable } from './rate-limit-errors';
13
+ import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
14
+ import { storeDeadline } from './rate-limit-store-deadline';
15
+
16
+ /** The slice of Bun's `RedisClient` (core's `redisClient()`) this store sends through. */
17
+ export interface RedisRateLimitClient {
18
+ send(command: string, args: string[]): Promise<unknown>;
19
+ }
20
+
21
+ /**
22
+ * `KEYS[1]` the bucket; `ARGV` capacity, refill per second, cost, the caller's `nowMs`. The same
23
+ * arithmetic as the memory store and `SQL_RATE_LIMIT_TAKE`: refill capped at capacity, elapsed
24
+ * never negative, `l` only ever moves forward (two replicas share no clock). Tokens go back as a
25
+ * STRING — Redis truncates a Lua number reply to an integer, and 0.5 left is not 0. The key
26
+ * expires when it would be full again — the memory store's `forgetAt` — which answers exactly as a
27
+ * missing one. A week's window is a week's key: capping it would hand back a fresh allowance. A
28
+ * refill of 0 never reaches the script (`take` refuses it), so no key is held forever.
29
+ */
30
+ export const REDIS_RATE_LIMIT_TAKE = `
31
+ local cap = tonumber(ARGV[1])
32
+ local rate = tonumber(ARGV[2])
33
+ local cost = tonumber(ARGV[3])
34
+ local now = tonumber(ARGV[4])
35
+ local held = redis.call('HMGET', KEYS[1], 't', 'l')
36
+ local tokens = cap
37
+ local last = now
38
+ if held[1] then
39
+ tokens = tonumber(held[1])
40
+ last = tonumber(held[2])
41
+ end
42
+ tokens = math.min(cap, tokens + math.max(0, now - last) / 1000 * rate)
43
+ local allowed = tokens >= cost
44
+ if allowed then tokens = tokens - cost end
45
+ redis.call('HSET', KEYS[1], 't', string.format('%.17g', tokens), 'l', string.format('%d', math.max(last, now)))
46
+ redis.call('PEXPIRE', KEYS[1], math.max(1, math.ceil((cap - tokens) / rate * 1000)))
47
+ return { string.format('%.17g', tokens), allowed and 1 or 0 }
48
+ `;
49
+
50
+ const TAKE_SHA = new Bun.CryptoHasher('sha1').update(REDIS_RATE_LIMIT_TAKE).digest('hex');
51
+
52
+ export interface RedisRateLimitStoreOptions {
53
+ /** Core's `redisClient()` (`@ultimat3/core/redis`), or anything with its `send`. */
54
+ readonly client: RedisRateLimitClient;
55
+ /** Key namespace, so two apps can share one Redis. Default `x`; keys are `<prefix>:rl:<key>`. */
56
+ readonly prefix?: string | undefined;
57
+ /** One round trip's budget before the decision degrades to this pod's memory. Default 250 ms. */
58
+ readonly deadlineMs?: number | undefined;
59
+ /** Injection seam for that deadline; production passes none. */
60
+ readonly schedule?: Scheduler | undefined;
61
+ }
62
+
63
+ /** Bun's code for a command refused for want of a connection — the store is down, not wrong. */
64
+ const CONNECTION_CLOSED = 'ERR_REDIS_CONNECTION_CLOSED';
65
+
66
+ const unreachable = (error: unknown): boolean =>
67
+ isRateLimitStoreUnreachable(error) || stringField(error, 'code') === CONNECTION_CLOSED;
68
+
69
+ /** A reply list's member as a finite number, or `null` for anything else. */
70
+ const numberAt = (reply: unknown, index: number): number | null => {
71
+ if (!Array.isArray(reply)) return null;
72
+ const value: unknown = reply[index];
73
+ if (value === null || value === undefined) return null;
74
+ const parsed = Number(value);
75
+ return Number.isFinite(parsed) ? parsed : null;
76
+ };
77
+
78
+ /**
79
+ * **Name it in `apps/<app>/runtime.ts`**, the one place a store is chosen:
80
+ *
81
+ * ```ts
82
+ * import { redisClient } from '@ultimat3/core/redis';
83
+ * export const runtime = { rateLimitStore: redisRateLimitStore({ client: redisClient() }) };
84
+ * ```
85
+ *
86
+ * `scope: 'shared'`, so it satisfies `rateLimit: { scope: 'shared' }`. A Redis that is down or
87
+ * past `deadlineMs` degrades to this pod's memory, logged — the same rule and the same fallback
88
+ * as `postgresRateLimitStore` (`rate-limit-degrade.ts`); an error REPLY (a script the server
89
+ * refuses) still throws.
90
+ */
91
+ export function redisRateLimitStore(options: RedisRateLimitStoreOptions): RedisRateLimitStore {
92
+ const { client } = options;
93
+ const prefix = options.prefix ?? 'x';
94
+ const keyOf = (key: string): string => `${prefix}:rl:${key}`;
95
+ const deadline = storeDeadline(options.deadlineMs, options.schedule, 'redisRateLimitStore');
96
+ const fallback = degradedFallback('redisRateLimitStore');
97
+ const degrade = <T>(error: unknown, nowMs: number, decide: () => Promise<T>): Promise<T> => {
98
+ if (!unreachable(error)) throw error;
99
+ fallback.note(error, nowMs);
100
+ return decide();
101
+ };
102
+ // EVALSHA first: the script is sent once per server, not per request. A server that has not
103
+ // seen it (a restart, a failover) answers NOSCRIPT, and EVAL both runs and caches it.
104
+ const runTake = async (args: string[]): Promise<unknown> => {
105
+ try {
106
+ return await client.send('EVALSHA', [TAKE_SHA, '1', ...args]);
107
+ } catch (error) {
108
+ if (!(stringField(error, 'message') ?? '').includes('NOSCRIPT')) throw error;
109
+ return client.send('EVAL', [REDIS_RATE_LIMIT_TAKE, '1', ...args]);
110
+ }
111
+ };
112
+
113
+ return {
114
+ scope: 'shared',
115
+
116
+ async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
117
+ // Config resolution refuses it first; this is the store's own guard for a bucket handed in
118
+ // directly, because a key whose bucket never refills would never expire.
119
+ if (!Number.isFinite(bucket.refillPerSecond) || bucket.refillPerSecond <= 0) {
120
+ throw httpCountInvalid(
121
+ 'rateLimit bucket refillPerSecond',
122
+ bucket.refillPerSecond,
123
+ 'a finite number of tokens per second above 0',
124
+ 'rateLimit: { buckets: { default: { capacity: 120, refillPerSecond: 2 } } }',
125
+ );
126
+ }
127
+ let reply: unknown;
128
+ try {
129
+ reply = await deadline.bound(
130
+ 'take',
131
+ runTake([
132
+ keyOf(key),
133
+ String(bucket.capacity),
134
+ String(bucket.refillPerSecond),
135
+ String(cost),
136
+ String(Math.floor(nowMs)),
137
+ ]),
138
+ );
139
+ } catch (error) {
140
+ return degrade(error, nowMs, () => fallback.store.take(key, bucket, cost, nowMs));
141
+ }
142
+ const tokens = numberAt(reply, 0);
143
+ const allowed = numberAt(reply, 1);
144
+ // The script always answers both; anything else is not this script running, and inventing
145
+ // "allowed" would be the limiter silently switched off.
146
+ if (tokens === null || allowed === null) throw rateLimitStoreUnavailable('take');
147
+ return rateLimitDecision(bucket, tokens, cost, allowed === 1, nowMs);
148
+ },
149
+
150
+ async peek(key, bucket, nowMs) {
151
+ let reply: unknown;
152
+ try {
153
+ reply = await deadline.bound('peek', client.send('HMGET', [keyOf(key), 't', 'l']));
154
+ } catch (error) {
155
+ return degrade(error, nowMs, () => fallback.store.peek(key, bucket, nowMs));
156
+ }
157
+ const stored = numberAt(reply, 0);
158
+ const lastMs = numberAt(reply, 1);
159
+ const tokens =
160
+ stored === null || lastMs === null
161
+ ? bucket.capacity
162
+ : refilledTokens(bucket, stored, lastMs, Math.floor(nowMs));
163
+ return rateLimitPeek(bucket, tokens);
164
+ },
165
+
166
+ async reset(key): Promise<void> {
167
+ // Both places a decision can come from, the fallback first — the Postgres store's order.
168
+ await fallback.store.reset(key);
169
+ await client.send('DEL', [keyOf(key)]);
170
+ },
171
+ };
172
+ }
173
+
174
+ export interface RedisRateLimitStore extends RateLimitStore {
175
+ readonly scope: RateLimitScope;
176
+ }
@@ -2,6 +2,7 @@
2
2
  // has a caller, and the one allowance the `auth` stage spends for a request that failed to have
3
3
  // one. Split out of `stages.ts` at the size ceiling; the bucket maths stays in `rate-limit.ts`.
4
4
 
5
+ import { addressNetwork } from '@ultimat3/core';
5
6
  import { actorView, type RequestContext } from './context';
6
7
  import {
7
8
  type RateLimitConfig,
@@ -85,9 +86,9 @@ const routeBucketOf = (ctx: RequestContext, config: RateLimitConfig): string | n
85
86
  return meta?.rateLimit ?? config.defaultBucket;
86
87
  };
87
88
 
88
- /** The one key failed credentials are counted under: the address alone, never the route. */
89
+ /** The one key failed credentials are counted under: the address alone (an IPv6 /64), never the route. */
89
90
  const unauthenticatedKey = (ip: string | null): string =>
90
- `${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
91
+ `${UNAUTHENTICATED_SCOPE}|ip:${ip === null ? 'unknown' : addressNetwork(ip)}`;
91
92
 
92
93
  /**
93
94
  * The `auth` stage's GATE, before `authenticate()` on a required route: an address whose failure
@@ -26,11 +26,24 @@ export interface StoreDeadline {
26
26
  bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T>;
27
27
  }
28
28
 
29
- export const rateLimitStoreTimeout = (statement: string, deadlineMs: number): HttpError =>
29
+ /** Which store's factory a refusal names — the one the author raises `deadlineMs` on. */
30
+ export type SharedStoreOwner = 'postgresRateLimitStore' | 'redisRateLimitStore';
31
+
32
+ /** The command that checks the store a timeout names: no table indexed by a value. */
33
+ const fixFor = (owner: SharedStoreOwner): string =>
34
+ owner === 'redisRateLimitStore'
35
+ ? 'redis-cli -u "$REDIS_URL" --latency # a Redis that is slow rather than gone takes redisRateLimitStore({ client, deadlineMs }) raised in the boot that builds it'
36
+ : 'x doctor --json # names the database binding this pod cannot reach; a database that is slow rather than gone takes postgresRateLimitStore({ executor, deadlineMs }) raised in the boot that builds it';
37
+
38
+ export const rateLimitStoreTimeout = (
39
+ statement: string,
40
+ deadlineMs: number,
41
+ owner: SharedStoreOwner = 'postgresRateLimitStore',
42
+ ): HttpError =>
30
43
  new HttpError({
31
44
  code: 'X_RATE_LIMIT_STORE_TIMEOUT',
32
45
  cause: `the shared rate-limit store's ${statement} statement did not answer within ${String(deadlineMs)} ms, so this decision was taken from this pod's memory`,
33
- fix: 'x doctor --json # names the database binding this pod cannot reach; a database that is slow rather than gone takes postgresRateLimitStore({ executor, deadlineMs }) raised in the boot that builds it',
46
+ fix: fixFor(owner),
34
47
  meta: { statement, deadlineMs },
35
48
  });
36
49
 
@@ -41,18 +54,23 @@ export const rateLimitStoreTimeout = (statement: string, deadlineMs: number): Ht
41
54
  export function storeDeadline(
42
55
  deadlineMs: number = RATE_LIMIT_STORE_DEADLINE_MS,
43
56
  schedule: Scheduler = unrefSchedule,
57
+ owner: SharedStoreOwner = 'postgresRateLimitStore',
44
58
  ): StoreDeadline {
45
- finiteCount('postgresRateLimitStore', 'deadlineMs', deadlineMs, 1);
59
+ finiteCount(owner, 'deadlineMs', deadlineMs, 1);
60
+ const handle = owner === 'postgresRateLimitStore' ? 'executor' : 'client';
46
61
  assert(
47
62
  deadlineMs <= MAX_TIMER_MS,
48
- `postgresRateLimitStore deadlineMs is ${String(deadlineMs)}, past the longest delay a timer holds, so it would fire at once`,
49
- `postgresRateLimitStore({ executor, deadlineMs: ${String(RATE_LIMIT_STORE_DEADLINE_MS)} }) # or any whole number of ms up to ${String(MAX_TIMER_MS)}`,
63
+ `${owner} deadlineMs is ${String(deadlineMs)}, past the longest delay a timer holds, so it would fire at once`,
64
+ `${owner}({ ${handle}, deadlineMs: ${String(RATE_LIMIT_STORE_DEADLINE_MS)} }) # or any whole number of ms up to ${String(MAX_TIMER_MS)}`,
50
65
  );
51
66
  return {
52
67
  bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T> {
53
68
  let cancel: () => void = () => undefined;
54
69
  const late = new Promise<never>((_, reject) => {
55
- cancel = schedule(() => reject(rateLimitStoreTimeout(statement, deadlineMs)), deadlineMs);
70
+ cancel = schedule(
71
+ () => reject(rateLimitStoreTimeout(statement, deadlineMs, owner)),
72
+ deadlineMs,
73
+ );
56
74
  });
57
75
  return Promise.race([sending, late]).finally(() => cancel());
58
76
  },
package/src/rate-limit.ts CHANGED
@@ -1,16 +1,11 @@
1
1
  // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
2
- // dev/tests and against a shared tier in a multi-replica deployment — installed through
3
- // `httpServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
4
- // declaration; the bucket maths lives here so every driver agrees on the numbers.
5
- import { type Clock, systemClock } from '@ultimat3/core';
2
+ // dev/tests and against a shared tier in a multi-replica deployment — named as `rateLimitStore` in
3
+ // `apps/<app>/runtime.ts`, and refused at boot when its scope cannot keep the app's declaration;
4
+ // the bucket maths lives here so every driver agrees on the numbers.
5
+ import { addressNetwork, type Clock, liveClock } from '@ultimat3/core';
6
6
  import { httpCountInvalid } from './errors';
7
- import {
8
- rateLimited,
9
- rateLimitInvalid,
10
- rateLimitNotShared,
11
- rateLimitScopeUnset,
12
- tenantBucketUnknown,
13
- } from './rate-limit-errors';
7
+ import { DEFAULT_RATE_LIMIT } from './rate-limit-config';
8
+ import { rateLimited, rateLimitNotShared } from './rate-limit-errors';
14
9
  import { rateLimitHeaders } from './rate-limit-headers';
15
10
  import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
16
11
 
@@ -73,98 +68,6 @@ export interface RateLimitConfig {
73
68
  readonly scope: RateLimitScope;
74
69
  }
75
70
 
76
- /**
77
- * Everything the framework can decide on its own. `scope` is NOT here: one process is the only
78
- * thing a framework can promise without being told, and defaulting to it made "we did not ask"
79
- * indistinguishable from "the app said one replica" — so the chart's `replicas: 3` enforced every
80
- * number three times over, silently, and the boot check that exists for this
81
- * (`assertRateLimitScope`) never fired because it only reads a `'shared'` declaration. The
82
- * comment that used to sit here was right about the fact and wrong about the conclusion: ask.
83
- */
84
- export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
85
- enabled: true,
86
- defaultBucket: 'default',
87
- tenantBucket: null,
88
- buckets: {
89
- default: { capacity: 120, refillPerSecond: 2 },
90
- // Login/signup style endpoints: slow, no burst.
91
- auth: { capacity: 10, refillPerSecond: 0.2 },
92
- mutation: { capacity: 30, refillPerSecond: 1 },
93
- },
94
- };
95
-
96
- /**
97
- * `defineHttpConfig`'s one resolver for this slice, so the refusal happens where an author can
98
- * act on it rather than at the first request. A limiter that is switched off has nothing to be
99
- * wrong about, so `enabled: false` needs no declaration — and reads as `'process'`.
100
- */
101
- export const resolveRateLimitConfig = (
102
- input: Partial<RateLimitConfig> | undefined,
103
- ): RateLimitConfig => {
104
- const merged = { ...DEFAULT_RATE_LIMIT, ...input };
105
- // Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
106
- // through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
107
- // be the 120-burst read bucket — looser than what the author declared, and invisible.
108
- if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
109
- throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
110
- }
111
- if (input?.scope !== undefined) return { ...merged, scope: input.scope };
112
- if (!merged.enabled) return { ...merged, scope: 'process' };
113
- throw rateLimitScopeUnset();
114
- };
115
-
116
- /** How a route, an action or a query spells a limit before it becomes a `Bucket`. */
117
- export interface RateLimitDeclaration {
118
- /** The burst a caller may spend at once. */
119
- readonly limit: number;
120
- /** The window that refills it. */
121
- readonly windowMs: number;
122
- }
123
-
124
- /**
125
- * `{ limit, windowMs }` as the limiter's own vocabulary: `5 / 600_000ms` is five held, one back
126
- * every two minutes. The only conversion between a declaration and the enforcement, so the
127
- * numbers an OpenAPI operation publishes and the numbers `withRouteBuckets` registers cannot
128
- * drift. It lives HERE, beside `Bucket` and the maths, because `@ultimat3/action` and
129
- * `@ultimat3/query` are the same tier and can never import each other — a copy in one of them is
130
- * a second answer to "what does this limit mean" for the other.
131
- *
132
- * **The COMPUTED rate is validated, not just the two declared halves.** The division is where a
133
- * pair that reads fine becomes one the limiter cannot run on, in both directions:
134
- * `{ limit: Number.MAX_VALUE, windowMs: 1 }` computes to `Infinity` — a bucket that never empties,
135
- * which is the same "declared a limit, enforced nothing" as `windowMs: 0` — and a tiny limit over
136
- * a huge window underflows to `0`, a bucket that never refills, so the endpoint is closed after
137
- * its first burst rather than limited.
138
- */
139
- export const toBucket = (owner: string, declared: RateLimitDeclaration): Bucket => {
140
- const refuse = (reason: string): never => {
141
- throw rateLimitInvalid({
142
- owner,
143
- limit: declared.limit,
144
- windowMs: declared.windowMs,
145
- reason,
146
- });
147
- };
148
- // A capacity under one token cannot admit a single request, so the endpoint is closed, not
149
- // limited — a policy's job, never a rate limit's.
150
- if (!Number.isFinite(declared.limit) || declared.limit < 1) {
151
- refuse('limit must be a finite number of at least 1 request');
152
- }
153
- if (!Number.isFinite(declared.windowMs) || declared.windowMs <= 0) {
154
- refuse('windowMs must be finite and greater than zero');
155
- }
156
- const refillPerSecond = declared.limit / (declared.windowMs / 1000);
157
- // Kept though the two checks above make it unreachable today: with `limit >= 1` and a finite
158
- // window the smallest rate is ~5.6e-306, which is normal, not zero. It is the guard that has to
159
- // move first if `limit >= 1` is ever relaxed.
160
- if (!Number.isFinite(refillPerSecond) || refillPerSecond <= 0) {
161
- refuse(
162
- `the refill rate it computes to is ${refillPerSecond} per second, which is a bucket that never empties — nothing would be enforced`,
163
- );
164
- }
165
- return { capacity: declared.limit, refillPerSecond };
166
- };
167
-
168
71
  interface BucketState {
169
72
  tokens: number;
170
73
  lastMs: number;
@@ -255,8 +158,9 @@ const assertFiniteKeyCap = (option: string, value: number): number => {
255
158
  /**
256
159
  * Default driver: correct for one process, which is exactly dev and tests.
257
160
  *
258
- * Bounded, because the key falls back to the connection address: a scan rotating through an
259
- * IPv6 /64 mints a fresh key per request, and an unbounded map turns that into an OOM. Two
161
+ * Bounded, because the key falls back to the connection address: a scan rotating through
162
+ * IPv6 /64s (one key each since v31, core's `addressNetwork`) mints a fresh key per prefix, and an
163
+ * unbounded map turns that into an OOM. Two
260
164
  * rules keep it flat. A refilled bucket is *forgotten*, not evicted — it answers exactly as a
261
165
  * missing one, so dropping it costs nothing and a scanner's one-request buckets qualify within
262
166
  * a second. Only if that is not enough does the cap evict live state, and then the entries
@@ -378,7 +282,7 @@ export const rateLimitSpends = (
378
282
  : parts.orgId !== null
379
283
  ? `org:${parts.orgId}`
380
284
  : parts.ip !== null
381
- ? `ip:${parts.ip}`
285
+ ? `ip:${addressNetwork(parts.ip)}`
382
286
  : (parts.unattributed ?? 'ip:unknown');
383
287
  const spends: RateLimitSpend[] =
384
288
  buckets.route === null ? [] : [{ key: `${parts.routeName}|${subject}`, bucket: buckets.route }];
@@ -425,8 +329,9 @@ export const rateLimiter = (options: {
425
329
  clock?: Clock;
426
330
  }): RateLimiter => {
427
331
  const store = options.store ?? memoryRateLimitStore();
428
- const clock = options.clock ?? systemClock;
429
- const now = (): number => clock.now().getTime();
332
+ // Read at each call, never captured: with no clock passed, the limiter follows the `clock` port
333
+ // (`runtime.ts`, a test's `installClock`) even when that is installed after it was built.
334
+ const now = (): number => (options.clock ?? liveClock).now().getTime();
430
335
  // `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and
431
336
  // `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it
432
337
  // holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of