@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 +21 -0
- package/README.md +95 -0
- package/dist/index.cjs +293 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +259 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +9 -0
- package/dist/migrations/index.js.map +1 -0
- package/package.json +78 -0
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":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|