@voltro/plugin-ratelimit 0.81.0 → 0.83.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/CHANGELOG.md +1806 -6
- package/dist/index.d.ts +22 -8
- package/dist/index.js +110 -127
- package/dist/postgresStore-B5gTBWHd.js +159 -0
- package/dist/postgresStore.d.ts +37 -8
- package/dist/postgresStore.js +2 -51
- package/dist/redisStore.d.ts +6 -6
- package/package.json +5 -4
- package/dist/window-lyQ6LXhj.js +0 -117
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { ColumnBuilder } from '@voltro/database';
|
|
2
|
+
import { DataStore } from '@voltro/database';
|
|
1
3
|
import { Effect } from 'effect';
|
|
4
|
+
import { FieldDefinitions } from '@voltro/database';
|
|
2
5
|
import { RespClient } from '@voltro/kv/connection';
|
|
3
6
|
import { RpcKind } from '@voltro/protocol';
|
|
4
7
|
import { Schema } from 'effect';
|
|
5
|
-
import { SqlClient } from '@effect/sql';
|
|
6
8
|
import { Subject } from '@voltro/protocol';
|
|
9
|
+
import { Table } from '@voltro/database';
|
|
7
10
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
8
11
|
|
|
9
12
|
export declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
@@ -79,13 +82,15 @@ export declare interface MemoryStoreOptions {
|
|
|
79
82
|
* bad window is a config error that should surface loudly at boot. */
|
|
80
83
|
export declare const parseWindow: (spec: WindowSpec) => number;
|
|
81
84
|
|
|
85
|
+
export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
|
|
86
|
+
|
|
82
87
|
export declare interface PostgresStoreOptions {
|
|
83
|
-
/** Table name. Default `_voltro_ratelimit`. */
|
|
84
|
-
readonly table?: string;
|
|
85
88
|
/** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
|
|
86
89
|
readonly staleAfterMs?: number;
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
export declare const RATELIMIT_TABLE = "_voltro_ratelimit";
|
|
93
|
+
|
|
89
94
|
/** The context handed to every dynamic option + the `resolve` hook.
|
|
90
95
|
* Derived from the framework's RpcInterceptorContext. */
|
|
91
96
|
export declare interface RateLimitContext {
|
|
@@ -261,13 +266,22 @@ export declare interface RateLimitStore {
|
|
|
261
266
|
readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
|
|
262
267
|
/** Optional teardown at plugin deactivate (close pools). */
|
|
263
268
|
readonly deactivate?: () => Promise<void>;
|
|
264
|
-
/** Optional bind of the
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
readonly
|
|
269
|
+
/** Optional bind of the app's `DataStore` (the plugin's `bindDataStore`).
|
|
270
|
+
* A store kept in the app's database reads and writes through it — its
|
|
271
|
+
* table is declared through `extendSchema`, never created here. Not
|
|
272
|
+
* present on memory / redis stores. */
|
|
273
|
+
readonly bind?: (store: DataStore) => Promise<void>;
|
|
269
274
|
}
|
|
270
275
|
|
|
276
|
+
/** One row per rate-limit key. `id` + a unique `key` rather than `key` as the
|
|
277
|
+
* primary key: every dialect store addresses a row by `id`. */
|
|
278
|
+
export declare const rateLimitTable: Table<"_voltro_ratelimit", FieldDefinitions<{
|
|
279
|
+
readonly id: ColumnBuilder<number, "id", boolean>;
|
|
280
|
+
readonly key: ColumnBuilder<string, "text", boolean>;
|
|
281
|
+
readonly state: ColumnBuilder<BucketState | null, "json", boolean>;
|
|
282
|
+
readonly touchedAt: ColumnBuilder<Date, "timestamp", boolean>;
|
|
283
|
+
}>, false, never>;
|
|
284
|
+
|
|
271
285
|
export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
|
|
272
286
|
|
|
273
287
|
export declare interface RedisStoreOptions {
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { RateLimited as e } from "./errors.js";
|
|
2
|
-
import {
|
|
3
|
-
import { redisStore as
|
|
4
|
-
import { Effect as
|
|
5
|
-
import { createLogger as
|
|
6
|
-
import { replicaEvidence as
|
|
7
|
-
import { definePlugin as
|
|
2
|
+
import { a as t, i as n, n as r, r as i, t as a } from "./postgresStore-B5gTBWHd.js";
|
|
3
|
+
import { redisStore as o } from "./redisStore.js";
|
|
4
|
+
import { Effect as s } from "effect";
|
|
5
|
+
import { createLogger as c } from "@voltro/logger";
|
|
6
|
+
import { replicaEvidence as l } from "@voltro/protocol/identity";
|
|
7
|
+
import { definePlugin as u, pluginInstanceName as d } from "@voltro/protocol";
|
|
8
8
|
//#region src/inspect.ts
|
|
9
|
-
var
|
|
9
|
+
var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "function" ? "dynamic" : e, m = (e, n) => ({
|
|
10
10
|
id: n === null ? "default" : `r${n}`,
|
|
11
11
|
match: "match" in e ? e.match instanceof RegExp ? String(e.match) : Array.isArray(e.match) ? e.match.join(", ") : e.match ?? "*" : "*",
|
|
12
12
|
kinds: e.kind === void 0 ? [] : typeof e.kind == "string" ? [e.kind] : e.kind,
|
|
13
|
-
limit:
|
|
13
|
+
limit: f(e.limit),
|
|
14
14
|
windowMs: typeof e.window == "function" ? "dynamic" : t(e.window),
|
|
15
15
|
algorithm: e.algorithm ?? "sliding-window",
|
|
16
|
-
burst: e.burst === void 0 ?
|
|
17
|
-
by: Array.isArray(e.by) ? e.by.map(
|
|
16
|
+
burst: e.burst === void 0 ? f(e.limit) : f(e.burst),
|
|
17
|
+
by: Array.isArray(e.by) ? e.by.map(p) : [p(e.by ?? "subject")],
|
|
18
18
|
scope: e.scope ?? "tag",
|
|
19
19
|
tenantOverrides: Object.keys(e.tenants ?? {}).length
|
|
20
|
-
}),
|
|
20
|
+
}), h = () => ({
|
|
21
21
|
allowed: 0,
|
|
22
22
|
rejected: 0,
|
|
23
23
|
bypassed: 0,
|
|
@@ -25,11 +25,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
25
25
|
storeErrors: 0,
|
|
26
26
|
lastRejectedAt: null,
|
|
27
27
|
lastStoreErrorAt: null
|
|
28
|
-
}),
|
|
28
|
+
}), g = (e, n) => {
|
|
29
29
|
let r = Date.now(), i = {
|
|
30
|
-
rpc:
|
|
31
|
-
http:
|
|
32
|
-
}, a = n.
|
|
30
|
+
rpc: h(),
|
|
31
|
+
http: h()
|
|
32
|
+
}, a = n.bind ? "waiting" : "not-required";
|
|
33
33
|
return {
|
|
34
34
|
binding: (e) => {
|
|
35
35
|
a = e;
|
|
@@ -59,8 +59,8 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
59
59
|
sqlBinding: a,
|
|
60
60
|
trackedBuckets: o
|
|
61
61
|
},
|
|
62
|
-
rules: (e.rules ?? []).map((e, t) =>
|
|
63
|
-
defaultRule: e.default ?
|
|
62
|
+
rules: (e.rules ?? []).map((e, t) => m(e, t)),
|
|
63
|
+
defaultRule: e.default ? m(e.default, null) : null,
|
|
64
64
|
http: e.http ? {
|
|
65
65
|
limit: e.http.limit,
|
|
66
66
|
windowMs: t(e.http.window),
|
|
@@ -73,51 +73,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
|
-
},
|
|
77
|
-
let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5,
|
|
78
|
-
if (
|
|
79
|
-
let n =
|
|
80
|
-
for (let t of
|
|
76
|
+
}, _ = (e = {}) => {
|
|
77
|
+
let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, i = /* @__PURE__ */ new Map(), a = (e) => {
|
|
78
|
+
if (i.size <= t) return;
|
|
79
|
+
let n = i.size - t, r = 0;
|
|
80
|
+
for (let t of i.keys()) {
|
|
81
81
|
if (r >= n) break;
|
|
82
|
-
t !== e && (
|
|
82
|
+
t !== e && (i.delete(t), r += 1);
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
return {
|
|
86
|
-
consume: (e, t, r) =>
|
|
87
|
-
let
|
|
88
|
-
return
|
|
86
|
+
consume: (e, t, r) => s.sync(() => {
|
|
87
|
+
let o = i.get(e), { state: s, decision: c } = n(o?.state, t, r);
|
|
88
|
+
return i.set(e, {
|
|
89
89
|
state: s,
|
|
90
90
|
touched: r
|
|
91
|
-
}),
|
|
91
|
+
}), a(e), c;
|
|
92
92
|
}),
|
|
93
93
|
sweep: (e) => {
|
|
94
94
|
let t = e - r;
|
|
95
|
-
for (let [e, n] of
|
|
95
|
+
for (let [e, n] of i) n.touched < t && i.delete(e);
|
|
96
96
|
},
|
|
97
|
-
size: () =>
|
|
97
|
+
size: () => i.size
|
|
98
98
|
};
|
|
99
|
-
},
|
|
100
|
-
allowed: !0,
|
|
101
|
-
failOpenReason: "unbound",
|
|
102
|
-
remaining: e.limit,
|
|
103
|
-
resetAtMs: t + e.windowMs,
|
|
104
|
-
retryAfterMs: 0
|
|
105
|
-
}), g = (e = {}) => {
|
|
106
|
-
let t = null;
|
|
107
|
-
return {
|
|
108
|
-
consume: (e, n, r) => t ? t.consume(e, n, r) : i.succeed(h(n, r)),
|
|
109
|
-
sweep: (e) => t?.sweep(e),
|
|
110
|
-
size: () => t ? t.size() : -1,
|
|
111
|
-
bindSql: async (n) => {
|
|
112
|
-
let { postgresStore: r } = await import("./postgresStore.js"), i = r(e);
|
|
113
|
-
await i.bindSql(n), t = i;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
}, _ = (e, t) => typeof e == "function" ? e(t) : e, v = /* @__PURE__ */ new WeakMap(), y = (e) => {
|
|
99
|
+
}, v = (e, t) => typeof e == "function" ? e(t) : e, y = /* @__PURE__ */ new WeakMap(), b = (e) => {
|
|
117
100
|
if (!e.global && !e.sticky) return e;
|
|
118
|
-
let t =
|
|
119
|
-
return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")),
|
|
120
|
-
},
|
|
101
|
+
let t = y.get(e);
|
|
102
|
+
return t === void 0 && (t = new RegExp(e.source, e.flags.replace(/[gy]/g, "")), y.set(e, t)), t;
|
|
103
|
+
}, x = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : b(e).test(t), S = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, C = (e, t) => {
|
|
121
104
|
let n = t === null ? void 0 : e.tenants?.[t];
|
|
122
105
|
return {
|
|
123
106
|
limit: n?.limit ?? e.limit,
|
|
@@ -128,27 +111,27 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
128
111
|
scope: e.scope ?? "tag",
|
|
129
112
|
disabled: n?.disabled ?? !1
|
|
130
113
|
};
|
|
131
|
-
},
|
|
114
|
+
}, w = (e, t, n) => {
|
|
132
115
|
for (let t = 0; t < e.length; t++) {
|
|
133
116
|
let r = e[t];
|
|
134
|
-
if (
|
|
135
|
-
effective:
|
|
117
|
+
if (x(r.match, n.tag) && S(r.kind, n.kind)) return {
|
|
118
|
+
effective: C(r, n.tenantId),
|
|
136
119
|
ruleId: `r${t}`
|
|
137
120
|
};
|
|
138
121
|
}
|
|
139
|
-
if (t &&
|
|
140
|
-
effective:
|
|
122
|
+
if (t && S(t.kind, n.kind)) return {
|
|
123
|
+
effective: C(t, n.tenantId),
|
|
141
124
|
ruleId: "default"
|
|
142
125
|
};
|
|
143
|
-
},
|
|
144
|
-
let r =
|
|
126
|
+
}, T = (e, n) => {
|
|
127
|
+
let r = v(e.limit, n);
|
|
145
128
|
return {
|
|
146
129
|
limit: r,
|
|
147
|
-
windowMs: t(
|
|
130
|
+
windowMs: t(v(e.window, n)),
|
|
148
131
|
algorithm: e.algorithm ?? "sliding-window",
|
|
149
|
-
burst: e.burst === void 0 ? r :
|
|
132
|
+
burst: e.burst === void 0 ? r : v(e.burst, n)
|
|
150
133
|
};
|
|
151
|
-
},
|
|
134
|
+
}, E = (e) => {
|
|
152
135
|
let n = e.limit;
|
|
153
136
|
return {
|
|
154
137
|
limit: n,
|
|
@@ -156,12 +139,12 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
156
139
|
algorithm: e.algorithm ?? "sliding-window",
|
|
157
140
|
burst: e.burst ?? n
|
|
158
141
|
};
|
|
159
|
-
},
|
|
142
|
+
}, D = (e) => ({
|
|
160
143
|
limit: e.limit,
|
|
161
144
|
window: e.windowMs,
|
|
162
145
|
algorithm: e.algorithm,
|
|
163
146
|
burst: e.burst
|
|
164
|
-
}),
|
|
147
|
+
}), O = (e, t) => {
|
|
165
148
|
if (typeof e == "function") return e(t);
|
|
166
149
|
switch (e) {
|
|
167
150
|
case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
@@ -170,10 +153,10 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
170
153
|
case "global": return "g";
|
|
171
154
|
case "ip": return `i:${t.remoteAddr ?? "unknown"}`;
|
|
172
155
|
}
|
|
173
|
-
},
|
|
156
|
+
}, k = (e, t) => {
|
|
174
157
|
let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
|
|
175
|
-
return `${i}|${Array.isArray(a) ? a.map((e) =>
|
|
176
|
-
},
|
|
158
|
+
return `${i}|${Array.isArray(a) ? a.map((e) => O(e, t)).join("|") : O(a, t)}`;
|
|
159
|
+
}, A = (e) => ({
|
|
177
160
|
tag: e.tag,
|
|
178
161
|
kind: e.kind,
|
|
179
162
|
subject: e.subject,
|
|
@@ -182,7 +165,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
182
165
|
...e.surface === void 0 ? {} : { surface: e.surface },
|
|
183
166
|
...e.remoteAddr === void 0 ? {} : { remoteAddr: e.remoteAddr },
|
|
184
167
|
...e.surface === "rest" && e.publicApi?.rateLimit !== void 0 ? { publicApiRateLimit: e.publicApi.rateLimit } : {}
|
|
185
|
-
}),
|
|
168
|
+
}), j = (e) => e.publicApiRateLimit === void 0 ? void 0 : {
|
|
186
169
|
ruleId: "publicApi",
|
|
187
170
|
effective: {
|
|
188
171
|
limit: e.publicApiRateLimit.limit,
|
|
@@ -193,7 +176,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
193
176
|
scope: "tag",
|
|
194
177
|
disabled: !1
|
|
195
178
|
}
|
|
196
|
-
},
|
|
179
|
+
}, M = (e) => {
|
|
197
180
|
let t = e, n = Date.now();
|
|
198
181
|
return {
|
|
199
182
|
"retry-after": String(Math.max(1, Math.ceil(t.retryAfterMs / 1e3))),
|
|
@@ -201,11 +184,11 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
201
184
|
"ratelimit-remaining": "0",
|
|
202
185
|
"ratelimit-reset": String(Math.max(0, Math.ceil((t.resetAtMs - n) / 1e3)))
|
|
203
186
|
};
|
|
204
|
-
},
|
|
205
|
-
let a = n.rules ?? [],
|
|
187
|
+
}, N = c({ scope: "voltro:ratelimit" }), P = (n = {}) => {
|
|
188
|
+
let a = n.rules ?? [], c = n.store === void 0 || n.store === "memory" ? _() : n.store === "postgres" ? r() : n.store === "redis" ? o() : n.store, f = g(n, c), p = (e) => {
|
|
206
189
|
let t = n.enabled;
|
|
207
190
|
return t === void 0 ? !0 : typeof t == "function" ? t(e) : t;
|
|
208
|
-
},
|
|
191
|
+
}, m = (e, t, r, i) => {
|
|
209
192
|
if (!n.onLimited) return;
|
|
210
193
|
let a = {
|
|
211
194
|
tag: e.tag,
|
|
@@ -221,87 +204,87 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
221
204
|
} catch {}
|
|
222
205
|
}, h = (e, t) => {
|
|
223
206
|
let r = n.resolve;
|
|
224
|
-
return r ?
|
|
207
|
+
return r ? s.suspend(() => {
|
|
225
208
|
let n = r(e, t);
|
|
226
|
-
return (
|
|
227
|
-
}) :
|
|
228
|
-
},
|
|
229
|
-
let
|
|
230
|
-
if (!
|
|
231
|
-
let
|
|
232
|
-
if (!
|
|
233
|
-
let
|
|
234
|
-
return h(
|
|
235
|
-
if (n === "exempt") return
|
|
236
|
-
let r =
|
|
237
|
-
return
|
|
209
|
+
return (s.isEffect(n) ? n : n instanceof Promise ? s.promise(() => n) : s.succeed(n)).pipe(s.map((e) => e === "exempt" ? "exempt" : E(e === void 0 ? t : e)));
|
|
210
|
+
}) : s.succeed(E(t));
|
|
211
|
+
}, v = (t, r) => s.suspend(() => {
|
|
212
|
+
let i = A(r);
|
|
213
|
+
if (!p(i)) return f.bypass(), t;
|
|
214
|
+
let o = j(i) ?? w(a, n.default, i);
|
|
215
|
+
if (!o || o.effective.disabled) return f.bypass(), t;
|
|
216
|
+
let l = D(T(o.effective, i));
|
|
217
|
+
return h(i, l).pipe(s.flatMap((n) => {
|
|
218
|
+
if (n === "exempt") return f.bypass(), t;
|
|
219
|
+
let r = k(o, i);
|
|
220
|
+
return c.consume(r, n, Date.now()).pipe(s.catchAllDefect((e) => s.as(s.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
|
|
238
221
|
allowed: !0,
|
|
239
222
|
failOpenReason: "store-error",
|
|
240
223
|
remaining: n.limit,
|
|
241
224
|
resetAtMs: Date.now(),
|
|
242
225
|
retryAfterMs: 0
|
|
243
|
-
})),
|
|
244
|
-
tag:
|
|
226
|
+
})), s.tap((e) => s.sync(() => f.record("rpc", e))), s.flatMap((a) => a.allowed ? t : s.zipRight(s.sync(() => m(i, r, n, a)), s.fail(new e({
|
|
227
|
+
tag: i.tag,
|
|
245
228
|
limit: n.limit,
|
|
246
229
|
windowMs: n.windowMs,
|
|
247
230
|
retryAfterMs: a.retryAfterMs,
|
|
248
231
|
resetAtMs: a.resetAtMs
|
|
249
232
|
})))));
|
|
250
233
|
}));
|
|
251
|
-
}),
|
|
252
|
-
let
|
|
253
|
-
limit:
|
|
254
|
-
windowMs: t(
|
|
255
|
-
algorithm:
|
|
256
|
-
burst:
|
|
257
|
-
},
|
|
234
|
+
}), y = n.http ? async (e, r) => {
|
|
235
|
+
let i = n.http, a = {
|
|
236
|
+
limit: i.limit,
|
|
237
|
+
windowMs: t(i.window),
|
|
238
|
+
algorithm: i.algorithm ?? "fixed-window",
|
|
239
|
+
burst: i.limit
|
|
240
|
+
}, o = r.remoteAddr ?? "unknown", l = Date.now(), u;
|
|
258
241
|
try {
|
|
259
|
-
|
|
242
|
+
u = await s.runPromise(c.consume(`http:ip:${o}`, a, l));
|
|
260
243
|
} catch (t) {
|
|
261
|
-
return
|
|
244
|
+
return f.record("http", {
|
|
262
245
|
allowed: !0,
|
|
263
246
|
failOpenReason: "store-error",
|
|
264
247
|
remaining: 0,
|
|
265
|
-
resetAtMs:
|
|
248
|
+
resetAtMs: l,
|
|
266
249
|
retryAfterMs: 0
|
|
267
|
-
}),
|
|
250
|
+
}), N.warn("http shield store failure — request ALLOWED without a limit check", { cause: String(t) }), e();
|
|
268
251
|
}
|
|
269
|
-
|
|
270
|
-
let
|
|
271
|
-
return
|
|
252
|
+
f.record("http", u);
|
|
253
|
+
let d = String(Math.max(0, Math.ceil((u.resetAtMs - l) / 1e3)));
|
|
254
|
+
return u.allowed ? e() : {
|
|
272
255
|
status: 429,
|
|
273
256
|
body: "Too Many Requests",
|
|
274
257
|
headers: {
|
|
275
258
|
"content-type": "text/plain; charset=utf-8",
|
|
276
|
-
"retry-after": String(Math.max(1, Math.ceil(
|
|
277
|
-
"ratelimit-limit": String(
|
|
278
|
-
"ratelimit-remaining": String(Math.max(0,
|
|
279
|
-
"ratelimit-reset":
|
|
259
|
+
"retry-after": String(Math.max(1, Math.ceil(u.retryAfterMs / 1e3))),
|
|
260
|
+
"ratelimit-limit": String(a.limit),
|
|
261
|
+
"ratelimit-remaining": String(Math.max(0, u.remaining)),
|
|
262
|
+
"ratelimit-reset": d
|
|
280
263
|
}
|
|
281
264
|
};
|
|
282
|
-
} : void 0,
|
|
265
|
+
} : void 0, b = [
|
|
283
266
|
"inspect:read",
|
|
284
267
|
"rpc:intercept:mutation",
|
|
285
268
|
"rpc:intercept:query",
|
|
286
269
|
"rpc:intercept:action",
|
|
287
270
|
"rpc:intercept:workflow",
|
|
288
271
|
...n.http ? ["http:intercept"] : []
|
|
289
|
-
],
|
|
290
|
-
return
|
|
291
|
-
name:
|
|
272
|
+
], x, S;
|
|
273
|
+
return u({
|
|
274
|
+
name: d({
|
|
292
275
|
base: "@voltro/plugin-ratelimit",
|
|
293
276
|
instance: n.name
|
|
294
277
|
}),
|
|
295
278
|
description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
|
|
296
|
-
permissions:
|
|
279
|
+
permissions: b,
|
|
297
280
|
inspectEndpoints: [{
|
|
298
281
|
method: "GET",
|
|
299
282
|
path: "/state",
|
|
300
283
|
scope: "process",
|
|
301
284
|
description: "Declared rules and process-local limiting evidence, without bucket identities.",
|
|
302
|
-
handler: () =>
|
|
285
|
+
handler: () => s.sync(() => ({
|
|
303
286
|
kind: "json",
|
|
304
|
-
data:
|
|
287
|
+
data: f.snapshot()
|
|
305
288
|
}))
|
|
306
289
|
}],
|
|
307
290
|
declaredEnv: [
|
|
@@ -336,7 +319,7 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
336
319
|
description: "Fallback Upstash REST token for the http redis driver."
|
|
337
320
|
}
|
|
338
321
|
],
|
|
339
|
-
...
|
|
322
|
+
...y ? { onHttpRequest: y } : {},
|
|
340
323
|
errorSchemas: [{
|
|
341
324
|
schema: e,
|
|
342
325
|
import: {
|
|
@@ -345,34 +328,34 @@ var l = (e) => typeof e == "function" ? "dynamic" : e, u = (e) => typeof e == "f
|
|
|
345
328
|
},
|
|
346
329
|
rest: {
|
|
347
330
|
status: 429,
|
|
348
|
-
headers:
|
|
331
|
+
headers: M
|
|
349
332
|
}
|
|
350
333
|
}],
|
|
351
|
-
interceptMutation:
|
|
352
|
-
interceptQuery:
|
|
353
|
-
interceptAction:
|
|
354
|
-
interceptWorkflow:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
334
|
+
interceptMutation: v,
|
|
335
|
+
interceptQuery: v,
|
|
336
|
+
interceptAction: v,
|
|
337
|
+
interceptWorkflow: v,
|
|
338
|
+
...n.store === "postgres" ? { extendSchema: { tables: [i] } } : {},
|
|
339
|
+
bindDataStore: (e) => {
|
|
340
|
+
c.bind && (f.binding("binding"), c.bind(e).then(() => {
|
|
341
|
+
S = void 0, f.binding("bound");
|
|
359
342
|
}).catch((e) => {
|
|
360
|
-
|
|
343
|
+
f.binding("failed"), S = e;
|
|
361
344
|
}));
|
|
362
345
|
},
|
|
363
|
-
onActivate: (e) =>
|
|
364
|
-
|
|
365
|
-
let t = n.store === void 0 || n.store === "memory" ?
|
|
346
|
+
onActivate: (e) => s.gen(function* () {
|
|
347
|
+
c.activate && (yield* s.promise(() => c.activate(e.env))), x = setInterval(() => c.sweep(Date.now()), 6e4), x.unref?.(), S !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: S instanceof Error ? S.message : String(S) });
|
|
348
|
+
let t = n.store === void 0 || n.store === "memory" ? l(e.env) : null;
|
|
366
349
|
t !== null && e.logger.warn(`rate-limit: the counter store is PROCESS-LOCAL (the default), but ${t}, which means several replicas. Each pod counts on its own, so every limit is effectively multiplied by the number of replicas. Set \`store: 'redis'\` or \`store: 'postgres'\` to share the counters.`), e.logger.info("rate-limit active", {
|
|
367
350
|
rules: a.length,
|
|
368
351
|
hasDefault: n.default !== void 0,
|
|
369
352
|
store: typeof n.store == "string" ? n.store : n.store === void 0 ? "memory" : "custom"
|
|
370
353
|
});
|
|
371
354
|
}),
|
|
372
|
-
onDeactivate: () =>
|
|
373
|
-
|
|
355
|
+
onDeactivate: () => s.gen(function* () {
|
|
356
|
+
x && clearInterval(x), c.deactivate && (yield* s.promise(() => c.deactivate()));
|
|
374
357
|
})
|
|
375
358
|
});
|
|
376
359
|
};
|
|
377
360
|
//#endregion
|
|
378
|
-
export { e as RateLimited, n as consumeBucket,
|
|
361
|
+
export { a as RATELIMIT_TABLE, e as RateLimited, n as consumeBucket, _ as memoryStore, t as parseWindow, r as postgresStore, P as rateLimitPlugin, i as rateLimitTable, o as redisStore };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Effect as e } from "effect";
|
|
2
|
+
import { eq as t, id as n, json as r, lt as i, queryFor as a, table as o, text as s, timestamp as c } from "@voltro/database";
|
|
3
|
+
//#region src/window.ts
|
|
4
|
+
var l = {
|
|
5
|
+
ms: 1,
|
|
6
|
+
s: 1e3,
|
|
7
|
+
m: 6e4,
|
|
8
|
+
h: 36e5,
|
|
9
|
+
d: 864e5
|
|
10
|
+
}, u = (e) => {
|
|
11
|
+
if (typeof e == "number") {
|
|
12
|
+
if (!Number.isFinite(e) || e <= 0) throw Error(`@voltro/plugin-ratelimit: window must be a positive number, got ${e}`);
|
|
13
|
+
return e;
|
|
14
|
+
}
|
|
15
|
+
let t = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(e.trim());
|
|
16
|
+
if (!t) throw Error(`@voltro/plugin-ratelimit: invalid window "${e}" — use e.g. "1m", "10s", "500ms", "2h", "1d"`);
|
|
17
|
+
return Number(t[1]) * l[t[2]];
|
|
18
|
+
}, d = (e, t, n) => {
|
|
19
|
+
switch (t.algorithm) {
|
|
20
|
+
case "fixed-window": return f(e, t, n);
|
|
21
|
+
case "sliding-window": return p(e, t, n);
|
|
22
|
+
case "token-bucket": return m(e, t, n);
|
|
23
|
+
}
|
|
24
|
+
}, f = (e, t, n) => {
|
|
25
|
+
let r = e?.kind === "fixed" ? e.windowStart : n, i = e?.kind === "fixed" ? e.count : 0;
|
|
26
|
+
n - r >= t.windowMs && (r = n, i = 0);
|
|
27
|
+
let a = r + t.windowMs;
|
|
28
|
+
return i >= t.limit ? {
|
|
29
|
+
state: {
|
|
30
|
+
kind: "fixed",
|
|
31
|
+
windowStart: r,
|
|
32
|
+
count: i
|
|
33
|
+
},
|
|
34
|
+
decision: {
|
|
35
|
+
allowed: !1,
|
|
36
|
+
remaining: 0,
|
|
37
|
+
resetAtMs: a,
|
|
38
|
+
retryAfterMs: a - n
|
|
39
|
+
}
|
|
40
|
+
} : (i += 1, {
|
|
41
|
+
state: {
|
|
42
|
+
kind: "fixed",
|
|
43
|
+
windowStart: r,
|
|
44
|
+
count: i
|
|
45
|
+
},
|
|
46
|
+
decision: {
|
|
47
|
+
allowed: !0,
|
|
48
|
+
remaining: t.limit - i,
|
|
49
|
+
resetAtMs: a,
|
|
50
|
+
retryAfterMs: 0
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}, p = (e, t, n) => {
|
|
54
|
+
let r = t.windowMs, i = e?.kind === "sliding" ? e.currStart : n, a = e?.kind === "sliding" ? e.currCount : 0, o = e?.kind === "sliding" ? e.prevCount : 0, s = Math.floor((n - i) / r);
|
|
55
|
+
s >= 2 ? (o = 0, a = 0, i = n) : s === 1 && (o = a, a = 0, i += r);
|
|
56
|
+
let c = (r - (n - i)) / r, l = o * c + a, u = i + r;
|
|
57
|
+
return l >= t.limit ? {
|
|
58
|
+
state: {
|
|
59
|
+
kind: "sliding",
|
|
60
|
+
currStart: i,
|
|
61
|
+
currCount: a,
|
|
62
|
+
prevCount: o
|
|
63
|
+
},
|
|
64
|
+
decision: {
|
|
65
|
+
allowed: !1,
|
|
66
|
+
remaining: 0,
|
|
67
|
+
resetAtMs: u,
|
|
68
|
+
retryAfterMs: u - n
|
|
69
|
+
}
|
|
70
|
+
} : (a += 1, {
|
|
71
|
+
state: {
|
|
72
|
+
kind: "sliding",
|
|
73
|
+
currStart: i,
|
|
74
|
+
currCount: a,
|
|
75
|
+
prevCount: o
|
|
76
|
+
},
|
|
77
|
+
decision: {
|
|
78
|
+
allowed: !0,
|
|
79
|
+
remaining: Math.max(0, Math.floor(t.limit - l - 1)),
|
|
80
|
+
resetAtMs: u,
|
|
81
|
+
retryAfterMs: 0
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}, m = (e, t, n) => {
|
|
85
|
+
let r = t.burst, i = t.limit / t.windowMs, a = e?.kind === "token" ? e.tokens : r, o = e?.kind === "token" ? e.lastRefill : n;
|
|
86
|
+
if (a = Math.min(r, a + (n - o) * i), a >= 1) {
|
|
87
|
+
--a;
|
|
88
|
+
let e = n + Math.ceil((r - a) / i);
|
|
89
|
+
return {
|
|
90
|
+
state: {
|
|
91
|
+
kind: "token",
|
|
92
|
+
tokens: a,
|
|
93
|
+
lastRefill: n
|
|
94
|
+
},
|
|
95
|
+
decision: {
|
|
96
|
+
allowed: !0,
|
|
97
|
+
remaining: Math.floor(a),
|
|
98
|
+
resetAtMs: e,
|
|
99
|
+
retryAfterMs: 0
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let s = Math.ceil((1 - a) / i);
|
|
104
|
+
return {
|
|
105
|
+
state: {
|
|
106
|
+
kind: "token",
|
|
107
|
+
tokens: a,
|
|
108
|
+
lastRefill: n
|
|
109
|
+
},
|
|
110
|
+
decision: {
|
|
111
|
+
allowed: !1,
|
|
112
|
+
remaining: 0,
|
|
113
|
+
resetAtMs: n + s,
|
|
114
|
+
retryAfterMs: s
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}, h = "_voltro_ratelimit", g = o(h, {
|
|
118
|
+
id: n({ scheme: "numeric" }),
|
|
119
|
+
key: s().maxLength(512),
|
|
120
|
+
state: r().nullable(),
|
|
121
|
+
touchedAt: c()
|
|
122
|
+
}).unique("rateLimitKey", ["key"]).nonReactive(), _ = (n = {}) => {
|
|
123
|
+
let r = n.staleAfterMs ?? 36e5, o = null, s = (e, t, n) => ({
|
|
124
|
+
failOpenReason: n,
|
|
125
|
+
allowed: !0,
|
|
126
|
+
remaining: e.limit,
|
|
127
|
+
resetAtMs: t + e.windowMs,
|
|
128
|
+
retryAfterMs: 0
|
|
129
|
+
}), c = async (e, n, r, i) => {
|
|
130
|
+
let o = new Date(i);
|
|
131
|
+
return await e.insertIgnore(h, {
|
|
132
|
+
key: n,
|
|
133
|
+
state: null,
|
|
134
|
+
touchedAt: o
|
|
135
|
+
}, { conflictColumns: ["key"] }), e.transactional(async (e) => {
|
|
136
|
+
let { state: s, decision: c } = d((await e.query(a(g).where(t("key", n)).forUpdate().descriptor))[0]?.state ?? void 0 ?? void 0, r, i);
|
|
137
|
+
return await e.updateMany(h, {
|
|
138
|
+
state: s,
|
|
139
|
+
touchedAt: o
|
|
140
|
+
}, { where: t("key", n) }), c;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
consume: (t, n, r) => {
|
|
145
|
+
let i = o;
|
|
146
|
+
return i ? e.tryPromise(() => c(i, t, n, r)).pipe(e.catchAll(() => e.succeed(s(n, r, "store-error")))) : e.succeed(s(n, r, "unbound"));
|
|
147
|
+
},
|
|
148
|
+
sweep: (e) => {
|
|
149
|
+
let t = o;
|
|
150
|
+
t && t.deleteMany(h, { where: i("touchedAt", new Date(e - r)) }).catch(() => {});
|
|
151
|
+
},
|
|
152
|
+
size: () => -1,
|
|
153
|
+
bind: async (e) => {
|
|
154
|
+
o = e;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
export { u as a, d as i, _ as n, g as r, h as t };
|