@fonderie/rate-limit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fonderie, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @fonderie/rate-limit
2
+
3
+ Distributed rate limiting for `@fonderie-js` — an atomic token bucket, backed
4
+ by your PostgreSQL by default, with an in-memory store for single instances
5
+ and a Redis store for very high volume. Emits standard `RateLimit-*` headers.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @fonderie/rate-limit
11
+ ```
12
+
13
+ ## Why it exists
14
+
15
+ `@fonderie/auth` wires this in front of login, registration, password reset,
16
+ and MFA verification **by default** — so an app that just said "add login"
17
+ gets brute-force protection without asking. Use it directly to guard your own
18
+ routes.
19
+
20
+ ## Stores
21
+
22
+ All three implement one atomic operation (`consume`), so switching backends
23
+ never changes behavior — only where the counters live and how far they scale.
24
+
25
+ | Store | Use when | Atomicity |
26
+ | --- | --- | --- |
27
+ | `MemoryStore` | single instance, dev | single-threaded event loop |
28
+ | `StoreAdapterStore` | multiple instances on Postgres (default in auth) | one `INSERT … ON CONFLICT` upsert |
29
+ | `RedisStore` | millions of users / high write volume | one Lua `eval` |
30
+
31
+ `RedisStore` takes any client with an `eval()` method (ioredis, node-redis) —
32
+ this package depends on no Redis library. `StoreAdapterStore` ships a
33
+ `migrations/` subpath for its one table.
34
+
35
+ ## Use
36
+
37
+ ```ts
38
+ import { rateLimit, byIp, byBodyField, StoreAdapterStore } from '@fonderie/rate-limit';
39
+
40
+ const store = new StoreAdapterStore(myStoreAdapter);
41
+
42
+ // 10 requests / 15 min per IP AND 5 / 15 min per account — both must allow.
43
+ app.post('/auth/login', adapt(rateLimit(
44
+ { store, rule: { capacity: 10, refillPerSec: 10 / 900 }, key: byIp('login') },
45
+ { store, rule: { capacity: 5, refillPerSec: 5 / 900 }, key: byBodyField('login', 'email') },
46
+ )), loginHandler);
47
+ ```
48
+
49
+ Denied requests get `429` with `RateLimit-Limit` / `RateLimit-Remaining` /
50
+ `RateLimit-Reset` and `Retry-After`. `byIp` reads `ctx.meta.clientIp`, which
51
+ the Fonderie adapters resolve with explicit proxy trust (`TRUST_PROXY`).
52
+
53
+ ## License
54
+
55
+ MIT © Fonderie, Inc.
56
+
57
+ ## Deploying behind a proxy (nginx, Kubernetes ingress, load balancer)
58
+
59
+ `byIp()` reads the client IP that the Fonderie adapter resolved into
60
+ `ctx.meta.clientIp`. **Read this if you run behind any L7 proxy — the default
61
+ is wrong for you, on purpose.**
62
+
63
+ The default (`TRUST_PROXY` unset) ignores `X-Forwarded-For` and uses the raw
64
+ socket address, which is spoof-safe but means: behind nginx or a Kubernetes
65
+ ingress, the socket address is the *proxy's* IP for every request. Every
66
+ client collapses onto one bucket, the per-IP limit becomes global, and a
67
+ single attacker can lock out your entire user base.
68
+
69
+ There is no default that is both spoof-safe and correct behind a proxy — they
70
+ contradict. So you must declare your topology:
71
+
72
+ ```sh
73
+ # Number of trusted proxy hops between the internet and your app.
74
+ # One nginx / one ingress in front → 1.
75
+ TRUST_PROXY=1
76
+ ```
77
+
78
+ With `TRUST_PROXY=N`, the client is taken as the Nth-from-the-right entry of
79
+ `X-Forwarded-For` — anything a client spoofs to the left of your trusted
80
+ proxies is ignored. Also ensure your proxy actually sets `X-Forwarded-For`
81
+ (nginx-ingress does by default), and for Kubernetes `LoadBalancer` services
82
+ consider `externalTrafficPolicy: Local` to preserve the source IP.
83
+
84
+ When it detects the mismatch — a forwarding header present but `TRUST_PROXY`
85
+ unset and the socket on a private/loopback address — the framework logs a
86
+ one-time warning at request time.
87
+
88
+ ## Fail-open
89
+
90
+ `rateLimit()` **fails open by default**: if the store errors (database down,
91
+ Redis unreachable), the request is allowed through rather than rejected. This
92
+ is a deliberate availability-over-strictness choice — a limiter outage
93
+ shouldn't lock every user out of login. For endpoints where an unthrottled
94
+ request is worse than a rejected one, set `failClosed: true` per limit. Either
95
+ way, monitor your store: a silently failing limiter is a silently absent one.
package/dist/index.cjs ADDED
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MemoryStore: () => MemoryStore,
24
+ RedisStore: () => RedisStore,
25
+ StoreAdapterStore: () => StoreAdapterStore,
26
+ byBodyField: () => byBodyField,
27
+ byIp: () => byIp,
28
+ consumeFromBucket: () => consumeFromBucket,
29
+ fullRefillMs: () => fullRefillMs,
30
+ rateLimit: () => rateLimit
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+
34
+ // src/middleware.ts
35
+ var import_node_crypto = require("crypto");
36
+ var import_core = require("@fonderie/core");
37
+ function hashed(scope, ...parts) {
38
+ const h = (0, import_node_crypto.createHash)("sha256").update(parts.join("\0")).digest("base64url");
39
+ return `${scope}:${h}`;
40
+ }
41
+ function byIp(scope) {
42
+ return (ctx) => {
43
+ const ip = ctx.meta["clientIp"];
44
+ if (typeof ip !== "string" || ip.length === 0) return null;
45
+ return hashed(`${scope}:ip`, ipv6Prefix(ip));
46
+ };
47
+ }
48
+ function ipv6Prefix(ip) {
49
+ if (!ip.includes(":")) return ip;
50
+ const [head] = ip.split("%");
51
+ const groups = head.split("::");
52
+ let left = groups[0] ? groups[0].split(":") : [];
53
+ let right = groups[1] ? groups[1].split(":") : [];
54
+ if (groups.length === 2) {
55
+ const fill = 8 - left.length - right.length;
56
+ left = [...left, ...Array(Math.max(0, fill)).fill("0"), ...right];
57
+ }
58
+ return left.slice(0, 4).join(":") + "::/64";
59
+ }
60
+ function byBodyField(scope, field) {
61
+ return (ctx) => {
62
+ const body = ctx.meta["body"];
63
+ const v = body?.[field];
64
+ if (typeof v !== "string" || v.length === 0) return null;
65
+ const normalized = v.slice(0, 320).trim().toLowerCase();
66
+ if (normalized.length === 0) return null;
67
+ return hashed(`${scope}:${field}`, normalized);
68
+ };
69
+ }
70
+ function rateLimit(...limits) {
71
+ return async (ctx, next) => {
72
+ for (const limit of limits) {
73
+ const key = limit.key(ctx);
74
+ if (key === null) continue;
75
+ let result;
76
+ try {
77
+ result = await limit.store.consume(key, limit.rule);
78
+ } catch {
79
+ if (limit.failClosed) {
80
+ return (0, import_core.setApiResponse)(
81
+ import_core.HTTP.TOO_MANY_REQUESTS,
82
+ "RATE_LIMITED",
83
+ "Rate limiter unavailable. Please try again later."
84
+ );
85
+ }
86
+ continue;
87
+ }
88
+ if (!result.allowed) {
89
+ const resetSec = Math.ceil(result.retryAfterMs / 1e3);
90
+ const res = (0, import_core.setApiResponse)(
91
+ import_core.HTTP.TOO_MANY_REQUESTS,
92
+ "RATE_LIMITED",
93
+ "Too many requests. Please try again later.",
94
+ { retryAfter: resetSec }
95
+ );
96
+ res.headers.set("RateLimit-Limit", String(limit.rule.capacity));
97
+ res.headers.set("RateLimit-Remaining", "0");
98
+ res.headers.set("RateLimit-Reset", String(resetSec));
99
+ res.headers.set("Retry-After", String(resetSec));
100
+ return res;
101
+ }
102
+ }
103
+ return next();
104
+ };
105
+ }
106
+
107
+ // src/bucket.ts
108
+ function consumeFromBucket(state, rule, nowMs) {
109
+ const cost = rule.cost ?? 1;
110
+ const prevTokens = state ? state.tokens : rule.capacity;
111
+ const prevRefill = state ? state.lastRefillMs : nowMs;
112
+ const elapsedSec = Math.max(0, nowMs - prevRefill) / 1e3;
113
+ const refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);
114
+ if (refilled >= cost) {
115
+ const tokens = refilled - cost;
116
+ return {
117
+ next: { tokens, lastRefillMs: nowMs },
118
+ result: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 }
119
+ };
120
+ }
121
+ const deficit = cost - refilled;
122
+ const retryAfterMs = Math.ceil(deficit / rule.refillPerSec * 1e3);
123
+ return {
124
+ next: { tokens: refilled, lastRefillMs: nowMs },
125
+ result: { allowed: false, remaining: 0, retryAfterMs }
126
+ };
127
+ }
128
+ function fullRefillMs(rule) {
129
+ return Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
130
+ }
131
+
132
+ // src/stores/memory.ts
133
+ var MemoryStore = class _MemoryStore {
134
+ buckets = /* @__PURE__ */ new Map();
135
+ ops = 0;
136
+ // Sweep lazily every N operations rather than on a timer, so the store
137
+ // holds no open handle that keeps short-lived processes (tests, CLIs) alive.
138
+ static SWEEP_EVERY = 1024;
139
+ async consume(key, rule) {
140
+ const now = Date.now();
141
+ const { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);
142
+ this.buckets.set(key, next);
143
+ if (++this.ops % _MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);
144
+ return result;
145
+ }
146
+ sweep(rule, nowMs) {
147
+ const idleMs = fullRefillMs(rule);
148
+ for (const [key, state] of this.buckets) {
149
+ if (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);
150
+ }
151
+ }
152
+ // Test/ops introspection.
153
+ get size() {
154
+ return this.buckets.size;
155
+ }
156
+ };
157
+
158
+ // src/stores/store-adapter.ts
159
+ var REFILLED = `LEAST($2::double precision,
160
+ fonderie_rate_limits.tokens
161
+ + GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0
162
+ * $4::double precision)`;
163
+ var CONSUME_SQL = `
164
+ INSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)
165
+ VALUES (
166
+ $1,
167
+ GREATEST(0, $2::double precision - $3::double precision),
168
+ (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),
169
+ $2::double precision >= $3::double precision
170
+ )
171
+ ON CONFLICT (key) DO UPDATE SET
172
+ tokens = CASE
173
+ WHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision
174
+ ELSE ${REFILLED}
175
+ END,
176
+ granted = ${REFILLED} >= $3::double precision,
177
+ last_refill_ms = EXCLUDED.last_refill_ms
178
+ RETURNING tokens, granted
179
+ `;
180
+ var CLEAN_SQL = `
181
+ DELETE FROM fonderie_rate_limits
182
+ WHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1
183
+ `;
184
+ var StoreAdapterStore = class _StoreAdapterStore {
185
+ constructor(store) {
186
+ this.store = store;
187
+ }
188
+ store;
189
+ ops = 0;
190
+ static CLEAN_EVERY = 512;
191
+ async consume(key, rule) {
192
+ const cost = rule.cost ?? 1;
193
+ const rows = await this.store.query(
194
+ CONSUME_SQL,
195
+ [key, rule.capacity, cost, rule.refillPerSec]
196
+ );
197
+ const row = rows[0];
198
+ if (!row) throw new Error("[rate-limit] consume returned no row");
199
+ const tokens = Number(row.tokens);
200
+ if (++this.ops % _StoreAdapterStore.CLEAN_EVERY === 0) {
201
+ const idleMs = Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
202
+ this.store.query(CLEAN_SQL, [idleMs]).catch(() => {
203
+ });
204
+ }
205
+ if (row.granted) {
206
+ return { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };
207
+ }
208
+ return {
209
+ allowed: false,
210
+ remaining: 0,
211
+ retryAfterMs: Math.ceil((cost - tokens) / rule.refillPerSec * 1e3)
212
+ };
213
+ }
214
+ };
215
+
216
+ // src/stores/redis.ts
217
+ var CONSUME_LUA = `
218
+ local key = KEYS[1]
219
+ local capacity = tonumber(ARGV[1])
220
+ local cost = tonumber(ARGV[2])
221
+ local refill_per_sec = tonumber(ARGV[3])
222
+ local ttl_ms = tonumber(ARGV[4])
223
+
224
+ local t = redis.call('TIME')
225
+ local now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)
226
+
227
+ local state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')
228
+ local tokens = tonumber(state[1])
229
+ local last_refill = tonumber(state[2])
230
+
231
+ if tokens == nil then
232
+ tokens = capacity
233
+ last_refill = now_ms
234
+ end
235
+
236
+ local elapsed_sec = math.max(0, now_ms - last_refill) / 1000
237
+ local refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)
238
+
239
+ local allowed = 0
240
+ local new_tokens = refilled
241
+ if refilled >= cost then
242
+ allowed = 1
243
+ new_tokens = refilled - cost
244
+ end
245
+
246
+ redis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)
247
+ redis.call('PEXPIRE', key, ttl_ms)
248
+
249
+ return { allowed, tostring(new_tokens) }
250
+ `;
251
+ var RedisStore = class {
252
+ constructor(client, keyPrefix = "fonderie:rl:") {
253
+ this.client = client;
254
+ this.keyPrefix = keyPrefix;
255
+ }
256
+ client;
257
+ keyPrefix;
258
+ async consume(key, rule) {
259
+ const cost = rule.cost ?? 1;
260
+ const ttlMs = Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
261
+ const raw = await this.client.eval(
262
+ CONSUME_LUA,
263
+ 1,
264
+ this.keyPrefix + key,
265
+ rule.capacity,
266
+ cost,
267
+ rule.refillPerSec,
268
+ ttlMs
269
+ );
270
+ const allowed = raw[0] === 1;
271
+ const tokens = Number(raw[1]);
272
+ if (allowed) {
273
+ return { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };
274
+ }
275
+ return {
276
+ allowed: false,
277
+ remaining: 0,
278
+ retryAfterMs: Math.ceil((cost - tokens) / rule.refillPerSec * 1e3)
279
+ };
280
+ }
281
+ };
282
+ // Annotate the CommonJS export names for ESM import in node:
283
+ 0 && (module.exports = {
284
+ MemoryStore,
285
+ RedisStore,
286
+ StoreAdapterStore,
287
+ byBodyField,
288
+ byIp,
289
+ consumeFromBucket,
290
+ fullRefillMs,
291
+ rateLimit
292
+ });
293
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["// ── Public API ───────────────────────────────────────────────────\nexport type {\n\tIRateLimitRule,\n\tIConsumeResult,\n\tIRateLimitStore,\n\tIRedisEvalClient,\n} from './types';\n\nexport { rateLimit, byIp, byBodyField } from './middleware';\nexport type { IRateLimitOptions, KeyFn } from './middleware';\n\nexport { MemoryStore } from './stores/memory';\nexport { StoreAdapterStore } from './stores/store-adapter';\nexport { RedisStore } from './stores/redis';\n\n// Pure bucket math — exported for tests and custom stores.\nexport { consumeFromBucket, fullRefillMs } from './bucket';\nexport type { IBucketState } from './bucket';\n","import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = Date.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,66 @@
1
+ import { IFonderieContext, Middleware } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
3
+
4
+ interface IRateLimitRule {
5
+ capacity: number;
6
+ refillPerSec: number;
7
+ cost?: number;
8
+ }
9
+ interface IConsumeResult {
10
+ allowed: boolean;
11
+ remaining: number;
12
+ retryAfterMs: number;
13
+ }
14
+ interface IRateLimitStore {
15
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
16
+ }
17
+ interface IRedisEvalClient {
18
+ eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<unknown>;
19
+ }
20
+
21
+ type KeyFn = (ctx: IFonderieContext) => string | null;
22
+ declare function byIp(scope: string): KeyFn;
23
+ declare function byBodyField(scope: string, field: string): KeyFn;
24
+ interface IRateLimitOptions {
25
+ store: IRateLimitStore;
26
+ rule: IRateLimitRule;
27
+ key: KeyFn;
28
+ failClosed?: boolean;
29
+ }
30
+ declare function rateLimit(...limits: IRateLimitOptions[]): Middleware;
31
+
32
+ declare class MemoryStore implements IRateLimitStore {
33
+ private buckets;
34
+ private ops;
35
+ private static SWEEP_EVERY;
36
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
37
+ private sweep;
38
+ get size(): number;
39
+ }
40
+
41
+ declare class StoreAdapterStore implements IRateLimitStore {
42
+ private store;
43
+ private ops;
44
+ private static CLEAN_EVERY;
45
+ constructor(store: IStoreAdapter);
46
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
47
+ }
48
+
49
+ declare class RedisStore implements IRateLimitStore {
50
+ private client;
51
+ private keyPrefix;
52
+ constructor(client: IRedisEvalClient, keyPrefix?: string);
53
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
54
+ }
55
+
56
+ interface IBucketState {
57
+ tokens: number;
58
+ lastRefillMs: number;
59
+ }
60
+ declare function consumeFromBucket(state: IBucketState | null, rule: IRateLimitRule, nowMs: number): {
61
+ next: IBucketState;
62
+ result: IConsumeResult;
63
+ };
64
+ declare function fullRefillMs(rule: IRateLimitRule): number;
65
+
66
+ export { type IBucketState, type IConsumeResult, type IRateLimitOptions, type IRateLimitRule, type IRateLimitStore, type IRedisEvalClient, type KeyFn, MemoryStore, RedisStore, StoreAdapterStore, byBodyField, byIp, consumeFromBucket, fullRefillMs, rateLimit };
@@ -0,0 +1,66 @@
1
+ import { IFonderieContext, Middleware } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
3
+
4
+ interface IRateLimitRule {
5
+ capacity: number;
6
+ refillPerSec: number;
7
+ cost?: number;
8
+ }
9
+ interface IConsumeResult {
10
+ allowed: boolean;
11
+ remaining: number;
12
+ retryAfterMs: number;
13
+ }
14
+ interface IRateLimitStore {
15
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
16
+ }
17
+ interface IRedisEvalClient {
18
+ eval(script: string, numKeys: number, ...keysAndArgs: (string | number)[]): Promise<unknown>;
19
+ }
20
+
21
+ type KeyFn = (ctx: IFonderieContext) => string | null;
22
+ declare function byIp(scope: string): KeyFn;
23
+ declare function byBodyField(scope: string, field: string): KeyFn;
24
+ interface IRateLimitOptions {
25
+ store: IRateLimitStore;
26
+ rule: IRateLimitRule;
27
+ key: KeyFn;
28
+ failClosed?: boolean;
29
+ }
30
+ declare function rateLimit(...limits: IRateLimitOptions[]): Middleware;
31
+
32
+ declare class MemoryStore implements IRateLimitStore {
33
+ private buckets;
34
+ private ops;
35
+ private static SWEEP_EVERY;
36
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
37
+ private sweep;
38
+ get size(): number;
39
+ }
40
+
41
+ declare class StoreAdapterStore implements IRateLimitStore {
42
+ private store;
43
+ private ops;
44
+ private static CLEAN_EVERY;
45
+ constructor(store: IStoreAdapter);
46
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
47
+ }
48
+
49
+ declare class RedisStore implements IRateLimitStore {
50
+ private client;
51
+ private keyPrefix;
52
+ constructor(client: IRedisEvalClient, keyPrefix?: string);
53
+ consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult>;
54
+ }
55
+
56
+ interface IBucketState {
57
+ tokens: number;
58
+ lastRefillMs: number;
59
+ }
60
+ declare function consumeFromBucket(state: IBucketState | null, rule: IRateLimitRule, nowMs: number): {
61
+ next: IBucketState;
62
+ result: IConsumeResult;
63
+ };
64
+ declare function fullRefillMs(rule: IRateLimitRule): number;
65
+
66
+ export { type IBucketState, type IConsumeResult, type IRateLimitOptions, type IRateLimitRule, type IRateLimitStore, type IRedisEvalClient, type KeyFn, MemoryStore, RedisStore, StoreAdapterStore, byBodyField, byIp, consumeFromBucket, fullRefillMs, rateLimit };
package/dist/index.js ADDED
@@ -0,0 +1,259 @@
1
+ // src/middleware.ts
2
+ import { createHash } from "crypto";
3
+ import { HTTP, setApiResponse } from "@fonderie/core";
4
+ function hashed(scope, ...parts) {
5
+ const h = createHash("sha256").update(parts.join("\0")).digest("base64url");
6
+ return `${scope}:${h}`;
7
+ }
8
+ function byIp(scope) {
9
+ return (ctx) => {
10
+ const ip = ctx.meta["clientIp"];
11
+ if (typeof ip !== "string" || ip.length === 0) return null;
12
+ return hashed(`${scope}:ip`, ipv6Prefix(ip));
13
+ };
14
+ }
15
+ function ipv6Prefix(ip) {
16
+ if (!ip.includes(":")) return ip;
17
+ const [head] = ip.split("%");
18
+ const groups = head.split("::");
19
+ let left = groups[0] ? groups[0].split(":") : [];
20
+ let right = groups[1] ? groups[1].split(":") : [];
21
+ if (groups.length === 2) {
22
+ const fill = 8 - left.length - right.length;
23
+ left = [...left, ...Array(Math.max(0, fill)).fill("0"), ...right];
24
+ }
25
+ return left.slice(0, 4).join(":") + "::/64";
26
+ }
27
+ function byBodyField(scope, field) {
28
+ return (ctx) => {
29
+ const body = ctx.meta["body"];
30
+ const v = body?.[field];
31
+ if (typeof v !== "string" || v.length === 0) return null;
32
+ const normalized = v.slice(0, 320).trim().toLowerCase();
33
+ if (normalized.length === 0) return null;
34
+ return hashed(`${scope}:${field}`, normalized);
35
+ };
36
+ }
37
+ function rateLimit(...limits) {
38
+ return async (ctx, next) => {
39
+ for (const limit of limits) {
40
+ const key = limit.key(ctx);
41
+ if (key === null) continue;
42
+ let result;
43
+ try {
44
+ result = await limit.store.consume(key, limit.rule);
45
+ } catch {
46
+ if (limit.failClosed) {
47
+ return setApiResponse(
48
+ HTTP.TOO_MANY_REQUESTS,
49
+ "RATE_LIMITED",
50
+ "Rate limiter unavailable. Please try again later."
51
+ );
52
+ }
53
+ continue;
54
+ }
55
+ if (!result.allowed) {
56
+ const resetSec = Math.ceil(result.retryAfterMs / 1e3);
57
+ const res = setApiResponse(
58
+ HTTP.TOO_MANY_REQUESTS,
59
+ "RATE_LIMITED",
60
+ "Too many requests. Please try again later.",
61
+ { retryAfter: resetSec }
62
+ );
63
+ res.headers.set("RateLimit-Limit", String(limit.rule.capacity));
64
+ res.headers.set("RateLimit-Remaining", "0");
65
+ res.headers.set("RateLimit-Reset", String(resetSec));
66
+ res.headers.set("Retry-After", String(resetSec));
67
+ return res;
68
+ }
69
+ }
70
+ return next();
71
+ };
72
+ }
73
+
74
+ // src/bucket.ts
75
+ function consumeFromBucket(state, rule, nowMs) {
76
+ const cost = rule.cost ?? 1;
77
+ const prevTokens = state ? state.tokens : rule.capacity;
78
+ const prevRefill = state ? state.lastRefillMs : nowMs;
79
+ const elapsedSec = Math.max(0, nowMs - prevRefill) / 1e3;
80
+ const refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);
81
+ if (refilled >= cost) {
82
+ const tokens = refilled - cost;
83
+ return {
84
+ next: { tokens, lastRefillMs: nowMs },
85
+ result: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 }
86
+ };
87
+ }
88
+ const deficit = cost - refilled;
89
+ const retryAfterMs = Math.ceil(deficit / rule.refillPerSec * 1e3);
90
+ return {
91
+ next: { tokens: refilled, lastRefillMs: nowMs },
92
+ result: { allowed: false, remaining: 0, retryAfterMs }
93
+ };
94
+ }
95
+ function fullRefillMs(rule) {
96
+ return Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
97
+ }
98
+
99
+ // src/stores/memory.ts
100
+ var MemoryStore = class _MemoryStore {
101
+ buckets = /* @__PURE__ */ new Map();
102
+ ops = 0;
103
+ // Sweep lazily every N operations rather than on a timer, so the store
104
+ // holds no open handle that keeps short-lived processes (tests, CLIs) alive.
105
+ static SWEEP_EVERY = 1024;
106
+ async consume(key, rule) {
107
+ const now = Date.now();
108
+ const { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);
109
+ this.buckets.set(key, next);
110
+ if (++this.ops % _MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);
111
+ return result;
112
+ }
113
+ sweep(rule, nowMs) {
114
+ const idleMs = fullRefillMs(rule);
115
+ for (const [key, state] of this.buckets) {
116
+ if (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);
117
+ }
118
+ }
119
+ // Test/ops introspection.
120
+ get size() {
121
+ return this.buckets.size;
122
+ }
123
+ };
124
+
125
+ // src/stores/store-adapter.ts
126
+ var REFILLED = `LEAST($2::double precision,
127
+ fonderie_rate_limits.tokens
128
+ + GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0
129
+ * $4::double precision)`;
130
+ var CONSUME_SQL = `
131
+ INSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)
132
+ VALUES (
133
+ $1,
134
+ GREATEST(0, $2::double precision - $3::double precision),
135
+ (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),
136
+ $2::double precision >= $3::double precision
137
+ )
138
+ ON CONFLICT (key) DO UPDATE SET
139
+ tokens = CASE
140
+ WHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision
141
+ ELSE ${REFILLED}
142
+ END,
143
+ granted = ${REFILLED} >= $3::double precision,
144
+ last_refill_ms = EXCLUDED.last_refill_ms
145
+ RETURNING tokens, granted
146
+ `;
147
+ var CLEAN_SQL = `
148
+ DELETE FROM fonderie_rate_limits
149
+ WHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1
150
+ `;
151
+ var StoreAdapterStore = class _StoreAdapterStore {
152
+ constructor(store) {
153
+ this.store = store;
154
+ }
155
+ store;
156
+ ops = 0;
157
+ static CLEAN_EVERY = 512;
158
+ async consume(key, rule) {
159
+ const cost = rule.cost ?? 1;
160
+ const rows = await this.store.query(
161
+ CONSUME_SQL,
162
+ [key, rule.capacity, cost, rule.refillPerSec]
163
+ );
164
+ const row = rows[0];
165
+ if (!row) throw new Error("[rate-limit] consume returned no row");
166
+ const tokens = Number(row.tokens);
167
+ if (++this.ops % _StoreAdapterStore.CLEAN_EVERY === 0) {
168
+ const idleMs = Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
169
+ this.store.query(CLEAN_SQL, [idleMs]).catch(() => {
170
+ });
171
+ }
172
+ if (row.granted) {
173
+ return { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };
174
+ }
175
+ return {
176
+ allowed: false,
177
+ remaining: 0,
178
+ retryAfterMs: Math.ceil((cost - tokens) / rule.refillPerSec * 1e3)
179
+ };
180
+ }
181
+ };
182
+
183
+ // src/stores/redis.ts
184
+ var CONSUME_LUA = `
185
+ local key = KEYS[1]
186
+ local capacity = tonumber(ARGV[1])
187
+ local cost = tonumber(ARGV[2])
188
+ local refill_per_sec = tonumber(ARGV[3])
189
+ local ttl_ms = tonumber(ARGV[4])
190
+
191
+ local t = redis.call('TIME')
192
+ local now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)
193
+
194
+ local state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')
195
+ local tokens = tonumber(state[1])
196
+ local last_refill = tonumber(state[2])
197
+
198
+ if tokens == nil then
199
+ tokens = capacity
200
+ last_refill = now_ms
201
+ end
202
+
203
+ local elapsed_sec = math.max(0, now_ms - last_refill) / 1000
204
+ local refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)
205
+
206
+ local allowed = 0
207
+ local new_tokens = refilled
208
+ if refilled >= cost then
209
+ allowed = 1
210
+ new_tokens = refilled - cost
211
+ end
212
+
213
+ redis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)
214
+ redis.call('PEXPIRE', key, ttl_ms)
215
+
216
+ return { allowed, tostring(new_tokens) }
217
+ `;
218
+ var RedisStore = class {
219
+ constructor(client, keyPrefix = "fonderie:rl:") {
220
+ this.client = client;
221
+ this.keyPrefix = keyPrefix;
222
+ }
223
+ client;
224
+ keyPrefix;
225
+ async consume(key, rule) {
226
+ const cost = rule.cost ?? 1;
227
+ const ttlMs = Math.ceil(rule.capacity / rule.refillPerSec * 1e3);
228
+ const raw = await this.client.eval(
229
+ CONSUME_LUA,
230
+ 1,
231
+ this.keyPrefix + key,
232
+ rule.capacity,
233
+ cost,
234
+ rule.refillPerSec,
235
+ ttlMs
236
+ );
237
+ const allowed = raw[0] === 1;
238
+ const tokens = Number(raw[1]);
239
+ if (allowed) {
240
+ return { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };
241
+ }
242
+ return {
243
+ allowed: false,
244
+ remaining: 0,
245
+ retryAfterMs: Math.ceil((cost - tokens) / rule.refillPerSec * 1e3)
246
+ };
247
+ }
248
+ };
249
+ export {
250
+ MemoryStore,
251
+ RedisStore,
252
+ StoreAdapterStore,
253
+ byBodyField,
254
+ byIp,
255
+ consumeFromBucket,
256
+ fullRefillMs,
257
+ rateLimit
258
+ };
259
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/middleware.ts","../src/bucket.ts","../src/stores/memory.ts","../src/stores/store-adapter.ts","../src/stores/redis.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\nimport type { IFonderieContext, Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\n\nimport type { IRateLimitRule, IRateLimitStore } from './types';\n\n// Key extractors. A limiter guards a scarce thing — name it in the key so\n// two limiters on the same route can't collide.\n//\n// Every key is hashed to a fixed-width digest before it reaches a store:\n// - bounds key size (an attacker can't blow up storage with 10KB \"emails\")\n// - keeps user identifiers (emails, IPs) OUT of the rate-limit table as\n// plaintext — no PII to leak or to forget under a deletion request\n// The `scope` prefix stays readable so operators can eyeball which limiter a\n// key belongs to; only the identifying tail is digested.\n\nexport type KeyFn = (ctx: IFonderieContext) => string | null;\n\nfunction hashed(scope: string, ...parts: string[]): string {\n\tconst h = createHash('sha256').update(parts.join('\\0')).digest('base64url');\n\treturn `${scope}:${h}`;\n}\n\n// Client IP, as resolved by the adapter into ctx.meta['clientIp'] (see\n// resolveClientIp in @fonderie/core/middlewares — trust-proxy aware). Returns\n// null when unavailable, which skips this limiter rather than collapsing every\n// request onto one shared key.\n//\n// IPv6 is keyed on the /64 prefix, not the full address: a single residential\n// IPv6 allocation is a /64 (2^64 addresses), so per-exact-address limiting is\n// trivially bypassed. IPv4 keys on the full address.\nexport function byIp(scope: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst ip = ctx.meta['clientIp'];\n\t\tif (typeof ip !== 'string' || ip.length === 0) return null;\n\t\treturn hashed(`${scope}:ip`, ipv6Prefix(ip));\n\t};\n}\n\n// Collapse an IPv6 address to its /64 network prefix; pass IPv4 through.\nfunction ipv6Prefix(ip: string): string {\n\tif (!ip.includes(':')) return ip; // IPv4\n\t// Expand omitted groups enough to take the first four (the /64 network).\n\tconst [head] = ip.split('%'); // strip zone id\n\tconst groups = head!.split('::');\n\tlet left = groups[0] ? groups[0].split(':') : [];\n\tlet right = groups[1] ? groups[1].split(':') : [];\n\tif (groups.length === 2) {\n\t\tconst fill = 8 - left.length - right.length;\n\t\tleft = [...left, ...Array(Math.max(0, fill)).fill('0'), ...right];\n\t}\n\treturn left.slice(0, 4).join(':') + '::/64';\n}\n\n// A field of the request body — e.g. the login email — normalized so\n// \"Jane@x.com\" and \"jane@x.com \" share a bucket, then hashed.\n//\n// SECURITY: place this limiter AFTER validate() in the route chain so the\n// field is a bounded, well-typed string before it becomes a key. On an\n// unvalidated body a caller could submit huge or non-string values; the\n// length guard below is a backstop, not the primary control.\nexport function byBodyField(scope: string, field: string): KeyFn {\n\treturn (ctx) => {\n\t\tconst body = ctx.meta['body'] as Record<string, unknown> | undefined;\n\t\tconst v = body?.[field];\n\t\tif (typeof v !== 'string' || v.length === 0) return null;\n\t\t// Backstop cap: an oversized value can't reach the hash unbounded.\n\t\tconst normalized = v.slice(0, 320).trim().toLowerCase();\n\t\tif (normalized.length === 0) return null;\n\t\treturn hashed(`${scope}:${field}`, normalized);\n\t};\n}\n\nexport interface IRateLimitOptions {\n\tstore: IRateLimitStore;\n\trule: IRateLimitRule;\n\tkey: KeyFn;\n\t// Fail-open (default) keeps auth available when the store is down —\n\t// an outage shouldn't lock every user out. Flip to fail-closed for\n\t// endpoints where an unthrottled request is worse than a rejected one.\n\t// This is a deliberate availability-over-strictness default; see the\n\t// package README § Fail-open.\n\tfailClosed?: boolean;\n}\n\n// One or more limits guarding a route; ALL must allow. Emits the IETF\n// draft-ietf-httpapi-ratelimit-headers fields on the 429 (RateLimit-Limit /\n// -Remaining / -Reset in seconds, plus Retry-After).\nexport function rateLimit(...limits: IRateLimitOptions[]): Middleware {\n\treturn async (ctx, next) => {\n\t\tfor (const limit of limits) {\n\t\t\tconst key = limit.key(ctx);\n\t\t\tif (key === null) continue;\n\n\t\t\tlet result: Awaited<ReturnType<IRateLimitStore['consume']>>;\n\t\t\ttry {\n\t\t\t\tresult = await limit.store.consume(key, limit.rule);\n\t\t\t} catch {\n\t\t\t\tif (limit.failClosed) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t\t'Rate limiter unavailable. Please try again later.',\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tcontinue; // fail-open\n\t\t\t}\n\n\t\t\tif (!result.allowed) {\n\t\t\t\tconst resetSec = Math.ceil(result.retryAfterMs / 1000);\n\t\t\t\tconst res = setApiResponse(\n\t\t\t\t\tHTTP.TOO_MANY_REQUESTS,\n\t\t\t\t\t'RATE_LIMITED',\n\t\t\t\t\t'Too many requests. Please try again later.',\n\t\t\t\t\t{ retryAfter: resetSec },\n\t\t\t\t);\n\t\t\t\tres.headers.set('RateLimit-Limit', String(limit.rule.capacity));\n\t\t\t\tres.headers.set('RateLimit-Remaining', '0');\n\t\t\t\tres.headers.set('RateLimit-Reset', String(resetSec));\n\t\t\t\tres.headers.set('Retry-After', String(resetSec));\n\t\t\t\treturn res;\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t};\n}\n","import type { IConsumeResult, IRateLimitRule } from './types';\n\n// Pure token-bucket math, shared by every store: given the persisted state\n// (tokens, lastRefillMs) and the current time, refill then try to consume.\n// Stores are responsible only for applying this atomically.\n\nexport interface IBucketState {\n\ttokens: number;\n\tlastRefillMs: number;\n}\n\nexport function consumeFromBucket(\n\tstate: IBucketState | null,\n\trule: IRateLimitRule,\n\tnowMs: number,\n): { next: IBucketState; result: IConsumeResult } {\n\tconst cost = rule.cost ?? 1;\n\tconst prevTokens = state ? state.tokens : rule.capacity;\n\tconst prevRefill = state ? state.lastRefillMs : nowMs;\n\n\tconst elapsedSec = Math.max(0, nowMs - prevRefill) / 1000;\n\tconst refilled = Math.min(rule.capacity, prevTokens + elapsedSec * rule.refillPerSec);\n\n\tif (refilled >= cost) {\n\t\tconst tokens = refilled - cost;\n\t\treturn {\n\t\t\tnext: { tokens, lastRefillMs: nowMs },\n\t\t\tresult: { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 },\n\t\t};\n\t}\n\n\tconst deficit = cost - refilled;\n\tconst retryAfterMs = Math.ceil((deficit / rule.refillPerSec) * 1000);\n\treturn {\n\t\tnext: { tokens: refilled, lastRefillMs: nowMs },\n\t\tresult: { allowed: false, remaining: 0, retryAfterMs },\n\t};\n}\n\n// How long until a full (idle) bucket forgets a key entirely — used by\n// stores for expiry so old keys don't accumulate forever.\nexport function fullRefillMs(rule: IRateLimitRule): number {\n\treturn Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n}\n","import { consumeFromBucket, fullRefillMs, type IBucketState } from '../bucket';\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Single-instance store. Atomic by virtue of the single-threaded event loop —\n// consume() does no awaiting between read and write. Correct for one process;\n// use StoreAdapterStore or RedisStore when running multiple instances.\n\nexport class MemoryStore implements IRateLimitStore {\n\tprivate buckets = new Map<string, IBucketState>();\n\tprivate ops = 0;\n\n\t// Sweep lazily every N operations rather than on a timer, so the store\n\t// holds no open handle that keeps short-lived processes (tests, CLIs) alive.\n\tprivate static SWEEP_EVERY = 1024;\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst now = Date.now();\n\t\tconst { next, result } = consumeFromBucket(this.buckets.get(key) ?? null, rule, now);\n\t\tthis.buckets.set(key, next);\n\n\t\tif (++this.ops % MemoryStore.SWEEP_EVERY === 0) this.sweep(rule, now);\n\t\treturn result;\n\t}\n\n\tprivate sweep(rule: IRateLimitRule, nowMs: number): void {\n\t\tconst idleMs = fullRefillMs(rule);\n\t\tfor (const [key, state] of this.buckets) {\n\t\t\t// A bucket idle long enough to be full again is indistinguishable\n\t\t\t// from an absent one — drop it.\n\t\t\tif (nowMs - state.lastRefillMs > idleMs) this.buckets.delete(key);\n\t\t}\n\t}\n\n\t// Test/ops introspection.\n\tget size(): number {\n\t\treturn this.buckets.size;\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IConsumeResult, IRateLimitRule, IRateLimitStore } from '../types';\n\n// Distributed store over the IStoreAdapter (PostgreSQL) every Fonderie module\n// already receives. Refill-then-consume happens in ONE upsert — the\n// ON CONFLICT UPDATE recomputes the bucket from the stored row inside the\n// row lock the statement takes, so N app instances hammering the same key\n// can never both win the last token. No transaction, no read-modify-write.\n//\n// TIME COMES FROM THE DATABASE, not the app. `clock_timestamp()` is evaluated\n// once in the VALUES clause and reused via EXCLUDED.last_refill_ms in the\n// UPDATE — so every app instance measures elapsed time against ONE\n// authoritative clock. This removes app-server clock skew from the refill\n// math entirely (making \"distributed-correct\" literally true, not\n// \"true assuming NTP\"). clock_timestamp() — not now()/transaction_timestamp()\n// — because we want real wall-clock at execution, and it MUST be captured\n// once: a second call would return a slightly later value and desync the two\n// places `now` is used.\n//\n// RETURNING only sees the post-update row, which cannot distinguish\n// \"allowed, bucket now low\" from \"denied, bucket unchanged\" — so the\n// allow/deny verdict is computed INSIDE the statement and persisted to the\n// `granted` column, then read back.\n//\n// Params: $1 key, $2 capacity, $3 cost, $4 refill_per_sec.\n// `refilled` = min(capacity, old.tokens + elapsed_sec * refill_per_sec),\n// where elapsed uses EXCLUDED.last_refill_ms (this call's DB `now`).\nconst REFILLED = `LEAST($2::double precision,\n\tfonderie_rate_limits.tokens\n\t+ GREATEST(0, EXCLUDED.last_refill_ms - fonderie_rate_limits.last_refill_ms) / 1000.0\n\t * $4::double precision)`;\n\nconst CONSUME_SQL = `\nINSERT INTO fonderie_rate_limits (key, tokens, last_refill_ms, granted)\nVALUES (\n\t$1,\n\tGREATEST(0, $2::double precision - $3::double precision),\n\t(EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0),\n\t$2::double precision >= $3::double precision\n)\nON CONFLICT (key) DO UPDATE SET\n\ttokens = CASE\n\t\tWHEN ${REFILLED} >= $3::double precision THEN ${REFILLED} - $3::double precision\n\t\tELSE ${REFILLED}\n\tEND,\n\tgranted = ${REFILLED} >= $3::double precision,\n\tlast_refill_ms = EXCLUDED.last_refill_ms\nRETURNING tokens, granted\n`;\n\n// Idle rows (past a full refill) are dead weight; prune them using DB time too.\nconst CLEAN_SQL = `\nDELETE FROM fonderie_rate_limits\nWHERE last_refill_ms < (EXTRACT(EPOCH FROM clock_timestamp()) * 1000.0) - $1\n`;\n\nexport class StoreAdapterStore implements IRateLimitStore {\n\tprivate ops = 0;\n\tprivate static CLEAN_EVERY = 512;\n\n\tconstructor(private store: IStoreAdapter) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\n\t\tconst rows = await this.store.query<{ tokens: number | string; granted: boolean }>(\n\t\t\tCONSUME_SQL,\n\t\t\t[key, rule.capacity, cost, rule.refillPerSec],\n\t\t);\n\t\tconst row = rows[0];\n\t\tif (!row) throw new Error('[rate-limit] consume returned no row');\n\n\t\tconst tokens = Number(row.tokens);\n\n\t\tif (++this.ops % StoreAdapterStore.CLEAN_EVERY === 0) {\n\t\t\tconst idleMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\t\t\tthis.store.query(CLEAN_SQL, [idleMs]).catch(() => {});\n\t\t}\n\n\t\tif (row.granted) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n","import type { IConsumeResult, IRateLimitRule, IRateLimitStore, IRedisEvalClient } from '../types';\n\n// High-throughput distributed store. Accepts any client exposing eval()\n// (ioredis and node-redis both do) — this package depends on no Redis\n// library. Refill-then-consume runs as one Lua script: Redis executes\n// scripts atomically, so cross-instance races are impossible by\n// construction. PEXPIRE gives free key expiry at full-refill time.\n//\n// TIME COMES FROM REDIS, not the app. `redis.call('TIME')` returns\n// [seconds, microseconds] from the Redis server clock, so every app instance\n// measures elapsed time against ONE authoritative clock — app-server skew\n// can't affect the refill math. (Effects-replication, default since Redis 5,\n// permits a non-deterministic read before writes; we target Redis 7.)\nconst CONSUME_LUA = `\nlocal key = KEYS[1]\nlocal capacity = tonumber(ARGV[1])\nlocal cost = tonumber(ARGV[2])\nlocal refill_per_sec = tonumber(ARGV[3])\nlocal ttl_ms = tonumber(ARGV[4])\n\nlocal t = redis.call('TIME')\nlocal now_ms = (tonumber(t[1]) * 1000) + (tonumber(t[2]) / 1000)\n\nlocal state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')\nlocal tokens = tonumber(state[1])\nlocal last_refill = tonumber(state[2])\n\nif tokens == nil then\n tokens = capacity\n last_refill = now_ms\nend\n\nlocal elapsed_sec = math.max(0, now_ms - last_refill) / 1000\nlocal refilled = math.min(capacity, tokens + elapsed_sec * refill_per_sec)\n\nlocal allowed = 0\nlocal new_tokens = refilled\nif refilled >= cost then\n allowed = 1\n new_tokens = refilled - cost\nend\n\nredis.call('HSET', key, 'tokens', new_tokens, 'last_refill_ms', now_ms)\nredis.call('PEXPIRE', key, ttl_ms)\n\nreturn { allowed, tostring(new_tokens) }\n`;\n\nexport class RedisStore implements IRateLimitStore {\n\tconstructor(\n\t\tprivate client: IRedisEvalClient,\n\t\tprivate keyPrefix = 'fonderie:rl:',\n\t) {}\n\n\tasync consume(key: string, rule: IRateLimitRule): Promise<IConsumeResult> {\n\t\tconst cost = rule.cost ?? 1;\n\t\tconst ttlMs = Math.ceil((rule.capacity / rule.refillPerSec) * 1000);\n\n\t\tconst raw = (await this.client.eval(\n\t\t\tCONSUME_LUA,\n\t\t\t1,\n\t\t\tthis.keyPrefix + key,\n\t\t\trule.capacity,\n\t\t\tcost,\n\t\t\trule.refillPerSec,\n\t\t\tttlMs,\n\t\t)) as [number, string];\n\n\t\tconst allowed = raw[0] === 1;\n\t\tconst tokens = Number(raw[1]);\n\n\t\tif (allowed) {\n\t\t\treturn { allowed: true, remaining: Math.floor(tokens), retryAfterMs: 0 };\n\t\t}\n\t\treturn {\n\t\t\tallowed: false,\n\t\t\tremaining: 0,\n\t\t\tretryAfterMs: Math.ceil(((cost - tokens) / rule.refillPerSec) * 1000),\n\t\t};\n\t}\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ declare function getMigrationsPath(): string;
2
+
3
+ export { getMigrationsPath };
@@ -0,0 +1,9 @@
1
+ // src/migrations/index.ts
2
+ import { createMigrationsPath } from "@fonderie/store";
3
+ function getMigrationsPath() {
4
+ return createMigrationsPath(import.meta.url);
5
+ }
6
+ export {
7
+ getMigrationsPath
8
+ };
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport function getMigrationsPath(): string {\n\treturn createMigrationsPath(import.meta.url);\n}\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,SAAS,oBAA4B;AAC3C,SAAO,qBAAqB,YAAY,GAAG;AAC5C;","names":[]}
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@fonderie/rate-limit",
3
+ "version": "0.1.0",
4
+ "description": "Distributed rate limiting for @fonderie-js — atomic token bucket over memory, PostgreSQL, or Redis, with standard RateLimit-* headers.",
5
+ "keywords": [
6
+ "fonderie-js",
7
+ "rate-limit",
8
+ "token-bucket",
9
+ "throttle",
10
+ "brute-force",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.cjs"
23
+ },
24
+ "./migrations": {
25
+ "types": "./dist/migrations/index.d.ts",
26
+ "import": "./dist/migrations/index.js"
27
+ }
28
+ },
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "scripts": {
33
+ "build": "tsup && tsup --config tsup.migrations.ts",
34
+ "dev": "tsup --watch",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "tsx --test src/__tests__/*.test.ts",
37
+ "lint": "biome lint src",
38
+ "format": "biome format --write src",
39
+ "check": "biome check --write src"
40
+ },
41
+ "peerDependencies": {
42
+ "@fonderie/core": "^0.1.1",
43
+ "@fonderie/store": "^0.1.1"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@fonderie/store": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@fonderie/core": "../core",
52
+ "@fonderie/store": "../store",
53
+ "@types/node": "^25.6.0",
54
+ "tsup": "^8.5.1",
55
+ "tsx": "^4.21.0",
56
+ "typescript": "^6.0.3",
57
+ "pg": "^8.13.3",
58
+ "@types/pg": "^8.11.10",
59
+ "redis": "^4.7.0"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "files": [
65
+ "dist",
66
+ "LICENSE",
67
+ "README.md"
68
+ ],
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "git+https://github.com/fonderie-js/sdk.git",
72
+ "directory": "packages/rate-limit"
73
+ },
74
+ "homepage": "https://github.com/fonderie-js/sdk/tree/main/packages/rate-limit#readme",
75
+ "bugs": {
76
+ "url": "https://github.com/fonderie-js/sdk/issues"
77
+ }
78
+ }