@voltro/plugin-ratelimit 0.84.0 → 0.86.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 +1215 -0
- package/THIRD-PARTY-NOTICES.md +3357 -12
- package/dist/errors.d.ts +8 -9
- package/dist/index.d.ts +12 -12
- package/dist/index.js +101 -232
- package/dist/inspect.js +72 -0
- package/dist/postgresStore.js +48 -2
- package/dist/redisStore.js +1 -1
- package/dist/resolve.js +65 -0
- package/dist/store.js +29 -0
- package/dist/window.js +117 -0
- package/package.json +7 -9
- package/dist/postgresStore-CDP61pxx.js +0 -160
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Schema } from 'effect';
|
|
2
|
+
import { YieldableError } from 'effect/Cause';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Raised on the RPC error channel when a call exceeds its limit. The
|
|
@@ -18,19 +19,17 @@ import { Schema } from 'effect';
|
|
|
18
19
|
export declare class RateLimited extends RateLimited_base {
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
declare const RateLimited_base: Schema.
|
|
22
|
-
readonly _tag: Schema.tag<"RateLimited">;
|
|
23
|
-
} & {
|
|
22
|
+
declare const RateLimited_base: Schema.Class<RateLimited, Schema.TaggedStruct<"RateLimited", {
|
|
24
23
|
/** The rpc tag that was limited, e.g. `'todos.create'`. */
|
|
25
|
-
tag:
|
|
24
|
+
readonly tag: Schema.String;
|
|
26
25
|
/** The effective limit that was hit. */
|
|
27
|
-
limit:
|
|
26
|
+
readonly limit: Schema.Number;
|
|
28
27
|
/** The window the limit applies over, in ms. */
|
|
29
|
-
windowMs:
|
|
28
|
+
readonly windowMs: Schema.Number;
|
|
30
29
|
/** Suggested wait before retrying, in ms. */
|
|
31
|
-
retryAfterMs:
|
|
30
|
+
readonly retryAfterMs: Schema.Number;
|
|
32
31
|
/** Epoch-ms when the bucket is expected to be replenished. */
|
|
33
|
-
resetAtMs:
|
|
34
|
-
}>;
|
|
32
|
+
readonly resetAtMs: Schema.Number;
|
|
33
|
+
}>, YieldableError>;
|
|
35
34
|
|
|
36
35
|
export { }
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { Schema } from 'effect';
|
|
|
8
8
|
import { Subject } from '@voltro/protocol';
|
|
9
9
|
import { Table } from '@voltro/database';
|
|
10
10
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
11
|
+
import { YieldableError } from 'effect/Cause';
|
|
11
12
|
|
|
12
13
|
export declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
13
14
|
|
|
@@ -100,14 +101,15 @@ export declare interface RateLimitContext {
|
|
|
100
101
|
/** Convenience: the subject's tenant (`null` only for unscoped anonymous). */
|
|
101
102
|
readonly tenantId: string | null;
|
|
102
103
|
readonly traceId: string;
|
|
103
|
-
/** `'rest'`
|
|
104
|
+
/** `'rest'` for a hand-authored REST route or `publicApi` projection; absent
|
|
104
105
|
* on the rpc wire. */
|
|
105
106
|
readonly surface?: 'rest';
|
|
106
|
-
/** The descriptor's own `publicApi.rateLimit`,
|
|
107
|
-
*
|
|
107
|
+
/** The descriptor's own REST quota — `rateLimit` or `publicApi.rateLimit`,
|
|
108
|
+
* ahead of `rules` and `default`. */
|
|
108
109
|
readonly publicApiRateLimit?: {
|
|
109
110
|
readonly limit: number;
|
|
110
111
|
readonly window: string;
|
|
112
|
+
readonly by?: 'subject' | 'tenant' | 'apiKey' | 'ip' | 'global';
|
|
111
113
|
};
|
|
112
114
|
/** The caller's address, resolved by the listener through the app's
|
|
113
115
|
* `security.trustedProxies` — what `by: 'ip'` keys on. Absent for a call
|
|
@@ -146,20 +148,18 @@ export declare interface RateLimitDecision {
|
|
|
146
148
|
export declare class RateLimited extends RateLimited_base {
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
declare const RateLimited_base: Schema.
|
|
150
|
-
readonly _tag: Schema.tag<"RateLimited">;
|
|
151
|
-
} & {
|
|
151
|
+
declare const RateLimited_base: Schema.Class<RateLimited, Schema.TaggedStruct<"RateLimited", {
|
|
152
152
|
/** The rpc tag that was limited, e.g. `'todos.create'`. */
|
|
153
|
-
tag:
|
|
153
|
+
readonly tag: Schema.String;
|
|
154
154
|
/** The effective limit that was hit. */
|
|
155
|
-
limit:
|
|
155
|
+
readonly limit: Schema.Number;
|
|
156
156
|
/** The window the limit applies over, in ms. */
|
|
157
|
-
windowMs:
|
|
157
|
+
readonly windowMs: Schema.Number;
|
|
158
158
|
/** Suggested wait before retrying, in ms. */
|
|
159
|
-
retryAfterMs:
|
|
159
|
+
readonly retryAfterMs: Schema.Number;
|
|
160
160
|
/** Epoch-ms when the bucket is expected to be replenished. */
|
|
161
|
-
resetAtMs:
|
|
162
|
-
}>;
|
|
161
|
+
readonly resetAtMs: Schema.Number;
|
|
162
|
+
}>, YieldableError>;
|
|
163
163
|
|
|
164
164
|
export declare const rateLimitPlugin: (options?: RateLimitPluginOptions) => VoltroPlugin;
|
|
165
165
|
|
package/dist/index.js
CHANGED
|
@@ -1,162 +1,18 @@
|
|
|
1
1
|
import { RateLimited as e } from "./errors.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
scope: e.scope ?? "tag",
|
|
19
|
-
tenantOverrides: Object.keys(e.tenants ?? {}).length
|
|
20
|
-
}), h = () => ({
|
|
21
|
-
allowed: 0,
|
|
22
|
-
rejected: 0,
|
|
23
|
-
bypassed: 0,
|
|
24
|
-
unbound: 0,
|
|
25
|
-
storeErrors: 0,
|
|
26
|
-
lastRejectedAt: null,
|
|
27
|
-
lastStoreErrorAt: null
|
|
28
|
-
}), g = (e, n) => {
|
|
29
|
-
let r = Date.now(), i = {
|
|
30
|
-
rpc: h(),
|
|
31
|
-
http: h()
|
|
32
|
-
}, a = n.bind ? "waiting" : "not-required";
|
|
33
|
-
return {
|
|
34
|
-
binding: (e) => {
|
|
35
|
-
a = e;
|
|
36
|
-
},
|
|
37
|
-
bypass: () => {
|
|
38
|
-
i.rpc.bypassed++;
|
|
39
|
-
},
|
|
40
|
-
record: (e, t) => {
|
|
41
|
-
let n = i[e];
|
|
42
|
-
t.failOpenReason === "unbound" ? n.unbound++ : t.failOpenReason === "store-error" ? (n.storeErrors++, n.lastStoreErrorAt = Date.now()) : t.allowed ? n.allowed++ : (n.rejected++, n.lastRejectedAt = Date.now());
|
|
43
|
-
},
|
|
44
|
-
snapshot: () => {
|
|
45
|
-
let o = null;
|
|
46
|
-
try {
|
|
47
|
-
let e = n.size();
|
|
48
|
-
Number.isFinite(e) && e >= 0 && (o = e);
|
|
49
|
-
} catch {}
|
|
50
|
-
let s = typeof e.store == "string" ? e.store : e.store === void 0 ? "memory" : "custom";
|
|
51
|
-
return {
|
|
52
|
-
capturedAt: Date.now(),
|
|
53
|
-
startedAt: r,
|
|
54
|
-
rpcGate: e.enabled === !1 ? "disabled" : typeof e.enabled == "function" ? "dynamic" : "enabled",
|
|
55
|
-
dynamicResolver: e.resolve !== void 0,
|
|
56
|
-
store: {
|
|
57
|
-
kind: s,
|
|
58
|
-
scope: s === "memory" ? "process" : s === "custom" ? "unknown" : "shared-store",
|
|
59
|
-
sqlBinding: a,
|
|
60
|
-
trackedBuckets: o
|
|
61
|
-
},
|
|
62
|
-
rules: (e.rules ?? []).map((e, t) => m(e, t)),
|
|
63
|
-
defaultRule: e.default ? m(e.default, null) : null,
|
|
64
|
-
http: e.http ? {
|
|
65
|
-
limit: e.http.limit,
|
|
66
|
-
windowMs: t(e.http.window),
|
|
67
|
-
algorithm: e.http.algorithm ?? "fixed-window"
|
|
68
|
-
} : null,
|
|
69
|
-
counters: {
|
|
70
|
-
rpc: { ...i.rpc },
|
|
71
|
-
http: { ...i.http }
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
};
|
|
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
|
-
if (r >= n) break;
|
|
82
|
-
t !== e && (i.delete(t), r += 1);
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
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
|
-
state: s,
|
|
90
|
-
touched: r
|
|
91
|
-
}), a(e), c;
|
|
92
|
-
}),
|
|
93
|
-
sweep: (e) => {
|
|
94
|
-
let t = e - r;
|
|
95
|
-
for (let [e, n] of i) n.touched < t && i.delete(e);
|
|
96
|
-
},
|
|
97
|
-
size: () => i.size
|
|
98
|
-
};
|
|
99
|
-
}, v = (e, t) => typeof e == "function" ? e(t) : e, y = /* @__PURE__ */ new WeakMap(), b = (e) => {
|
|
100
|
-
if (!e.global && !e.sticky) return e;
|
|
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) => {
|
|
104
|
-
let n = t === null ? void 0 : e.tenants?.[t];
|
|
105
|
-
return {
|
|
106
|
-
limit: n?.limit ?? e.limit,
|
|
107
|
-
window: n?.window ?? e.window,
|
|
108
|
-
algorithm: n?.algorithm ?? e.algorithm,
|
|
109
|
-
burst: n?.burst ?? e.burst,
|
|
110
|
-
by: e.by ?? "subject",
|
|
111
|
-
scope: e.scope ?? "tag",
|
|
112
|
-
disabled: n?.disabled ?? !1
|
|
113
|
-
};
|
|
114
|
-
}, w = (e, t, n) => {
|
|
115
|
-
for (let t = 0; t < e.length; t++) {
|
|
116
|
-
let r = e[t];
|
|
117
|
-
if (x(r.match, n.tag) && S(r.kind, n.kind)) return {
|
|
118
|
-
effective: C(r, n.tenantId),
|
|
119
|
-
ruleId: `r${t}`
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
if (t && S(t.kind, n.kind)) return {
|
|
123
|
-
effective: C(t, n.tenantId),
|
|
124
|
-
ruleId: "default"
|
|
125
|
-
};
|
|
126
|
-
}, T = (e, n) => {
|
|
127
|
-
let r = v(e.limit, n);
|
|
128
|
-
return {
|
|
129
|
-
limit: r,
|
|
130
|
-
windowMs: t(v(e.window, n)),
|
|
131
|
-
algorithm: e.algorithm ?? "sliding-window",
|
|
132
|
-
burst: e.burst === void 0 ? r : v(e.burst, n)
|
|
133
|
-
};
|
|
134
|
-
}, E = (e) => {
|
|
135
|
-
let n = e.limit;
|
|
136
|
-
return {
|
|
137
|
-
limit: n,
|
|
138
|
-
windowMs: t(e.window),
|
|
139
|
-
algorithm: e.algorithm ?? "sliding-window",
|
|
140
|
-
burst: e.burst ?? n
|
|
141
|
-
};
|
|
142
|
-
}, D = (e) => ({
|
|
143
|
-
limit: e.limit,
|
|
144
|
-
window: e.windowMs,
|
|
145
|
-
algorithm: e.algorithm,
|
|
146
|
-
burst: e.burst
|
|
147
|
-
}), O = (e, t) => {
|
|
148
|
-
if (typeof e == "function") return e(t);
|
|
149
|
-
switch (e) {
|
|
150
|
-
case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
151
|
-
case "tenant": return `t:${t.tenantId ?? "∅"}`;
|
|
152
|
-
case "apiKey": return t.subject.type === "apiKey" ? `k:${t.subject.id}` : `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
|
|
153
|
-
case "global": return "g";
|
|
154
|
-
case "ip": return `i:${t.remoteAddr ?? "unknown"}`;
|
|
155
|
-
}
|
|
156
|
-
}, k = (e, t) => {
|
|
157
|
-
let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
|
|
158
|
-
return `${i}|${Array.isArray(a) ? a.map((e) => O(e, t)).join("|") : O(a, t)}`;
|
|
159
|
-
}, A = (e) => ({
|
|
2
|
+
import { consumeBucket as t, parseWindow as n } from "./window.js";
|
|
3
|
+
import { makeRateLimitInspect as r } from "./inspect.js";
|
|
4
|
+
import { memoryStore as i } from "./store.js";
|
|
5
|
+
import { RATELIMIT_TABLE as a, postgresStore as o, rateLimitTable as s } from "./postgresStore.js";
|
|
6
|
+
import { redisStore as c } from "./redisStore.js";
|
|
7
|
+
import { buildKey as l, limitFromOverride as u, matchRule as d, resolveLimit as f, toOverride as p } from "./resolve.js";
|
|
8
|
+
import { Effect as m } from "effect";
|
|
9
|
+
import { restError as h } from "@voltro/protocol/rest";
|
|
10
|
+
import { createLogger as g } from "@voltro/logger";
|
|
11
|
+
import { newTraceId as _ } from "@voltro/runtime";
|
|
12
|
+
import { replicaEvidence as v } from "@voltro/protocol/identity";
|
|
13
|
+
import { definePlugin as y, pluginInstanceName as b } from "@voltro/protocol";
|
|
14
|
+
//#region src/index.ts
|
|
15
|
+
var x = (e) => ({
|
|
160
16
|
tag: e.tag,
|
|
161
17
|
kind: e.kind,
|
|
162
18
|
subject: e.subject,
|
|
@@ -165,18 +21,18 @@ var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "f
|
|
|
165
21
|
...e.surface === void 0 ? {} : { surface: e.surface },
|
|
166
22
|
...e.remoteAddr === void 0 ? {} : { remoteAddr: e.remoteAddr },
|
|
167
23
|
...e.surface === "rest" && e.publicApi?.rateLimit !== void 0 ? { publicApiRateLimit: e.publicApi.rateLimit } : {}
|
|
168
|
-
}),
|
|
24
|
+
}), S = (e) => e.publicApiRateLimit === void 0 ? void 0 : {
|
|
169
25
|
ruleId: "publicApi",
|
|
170
26
|
effective: {
|
|
171
27
|
limit: e.publicApiRateLimit.limit,
|
|
172
28
|
window: e.publicApiRateLimit.window,
|
|
173
29
|
algorithm: void 0,
|
|
174
30
|
burst: void 0,
|
|
175
|
-
by: "subject",
|
|
31
|
+
by: e.publicApiRateLimit.by ?? "subject",
|
|
176
32
|
scope: "tag",
|
|
177
33
|
disabled: !1
|
|
178
34
|
}
|
|
179
|
-
},
|
|
35
|
+
}, C = (e) => {
|
|
180
36
|
let t = e, n = Date.now();
|
|
181
37
|
return {
|
|
182
38
|
"retry-after": String(Math.max(1, Math.ceil(t.retryAfterMs / 1e3))),
|
|
@@ -184,108 +40,108 @@ var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "f
|
|
|
184
40
|
"ratelimit-remaining": "0",
|
|
185
41
|
"ratelimit-reset": String(Math.max(0, Math.ceil((t.resetAtMs - n) / 1e3)))
|
|
186
42
|
};
|
|
187
|
-
},
|
|
188
|
-
let a =
|
|
189
|
-
let
|
|
190
|
-
return
|
|
191
|
-
},
|
|
192
|
-
if (!
|
|
43
|
+
}, w = g({ scope: "voltro:ratelimit" }), T = (t = {}) => {
|
|
44
|
+
let a = t.rules ?? [], g = t.store === void 0 || t.store === "memory" ? i() : t.store === "postgres" ? o() : t.store === "redis" ? c() : t.store, T = r(t, g), E = (e) => {
|
|
45
|
+
let n = t.enabled;
|
|
46
|
+
return n === void 0 ? !0 : typeof n == "function" ? n(e) : n;
|
|
47
|
+
}, D = (e, n, r, i) => {
|
|
48
|
+
if (!t.onLimited) return;
|
|
193
49
|
let a = {
|
|
194
50
|
tag: e.tag,
|
|
195
51
|
kind: e.kind,
|
|
196
|
-
key:
|
|
52
|
+
key: n,
|
|
197
53
|
tenantId: e.tenantId,
|
|
198
54
|
limit: r.limit,
|
|
199
55
|
windowMs: r.windowMs,
|
|
200
56
|
retryAfterMs: i.retryAfterMs
|
|
201
57
|
};
|
|
202
58
|
try {
|
|
203
|
-
|
|
59
|
+
t.onLimited(a);
|
|
204
60
|
} catch {}
|
|
205
|
-
},
|
|
206
|
-
let r =
|
|
207
|
-
return r ?
|
|
208
|
-
let
|
|
209
|
-
return (
|
|
210
|
-
}) :
|
|
211
|
-
},
|
|
212
|
-
let i =
|
|
213
|
-
if (!
|
|
214
|
-
let o =
|
|
215
|
-
if (!o || o.effective.disabled) return
|
|
216
|
-
let
|
|
217
|
-
return
|
|
218
|
-
if (
|
|
219
|
-
let r =
|
|
220
|
-
return
|
|
61
|
+
}, O = (e, n) => {
|
|
62
|
+
let r = t.resolve;
|
|
63
|
+
return r ? m.suspend(() => {
|
|
64
|
+
let t = r(e, n);
|
|
65
|
+
return (m.isEffect(t) ? t : t instanceof Promise ? m.promise(() => t) : m.succeed(t)).pipe(m.map((e) => e === "exempt" ? "exempt" : u(e === void 0 ? n : e)));
|
|
66
|
+
}) : m.succeed(u(n));
|
|
67
|
+
}, k = (n, r) => m.suspend(() => {
|
|
68
|
+
let i = x(r);
|
|
69
|
+
if (!E(i)) return T.bypass(), n;
|
|
70
|
+
let o = S(i) ?? d(a, t.default, i);
|
|
71
|
+
if (!o || o.effective.disabled) return T.bypass(), n;
|
|
72
|
+
let s = p(f(o.effective, i));
|
|
73
|
+
return O(i, s).pipe(m.flatMap((t) => {
|
|
74
|
+
if (t === "exempt") return T.bypass(), n;
|
|
75
|
+
let r = l(o, i);
|
|
76
|
+
return g.consume(r, t, Date.now()).pipe(m.catchDefect((e) => m.as(m.logWarning(`[ratelimit] store error — failing open for ${i.tag}: ${String(e)}`), {
|
|
221
77
|
allowed: !0,
|
|
222
78
|
failOpenReason: "store-error",
|
|
223
|
-
remaining:
|
|
79
|
+
remaining: t.limit,
|
|
224
80
|
resetAtMs: Date.now(),
|
|
225
81
|
retryAfterMs: 0
|
|
226
|
-
})),
|
|
82
|
+
})), m.tap((e) => m.sync(() => T.record("rpc", e))), m.flatMap((a) => a.allowed ? n : m.andThen(m.sync(() => D(i, r, t, a)), m.fail(new e({
|
|
227
83
|
tag: i.tag,
|
|
228
|
-
limit:
|
|
229
|
-
windowMs:
|
|
84
|
+
limit: t.limit,
|
|
85
|
+
windowMs: t.windowMs,
|
|
230
86
|
retryAfterMs: a.retryAfterMs,
|
|
231
87
|
resetAtMs: a.resetAtMs
|
|
232
88
|
})))));
|
|
233
89
|
}));
|
|
234
|
-
}),
|
|
235
|
-
let i =
|
|
90
|
+
}), A = t.http ? async (e, r) => {
|
|
91
|
+
let i = t.http, a = {
|
|
236
92
|
limit: i.limit,
|
|
237
|
-
windowMs:
|
|
93
|
+
windowMs: n(i.window),
|
|
238
94
|
algorithm: i.algorithm ?? "fixed-window",
|
|
239
95
|
burst: i.limit
|
|
240
|
-
}, o = r.remoteAddr ?? "unknown",
|
|
96
|
+
}, o = r.remoteAddr ?? "unknown", s = Date.now(), c;
|
|
241
97
|
try {
|
|
242
|
-
|
|
98
|
+
c = await m.runPromise(g.consume(`http:ip:${o}`, a, s));
|
|
243
99
|
} catch (t) {
|
|
244
|
-
return
|
|
100
|
+
return T.record("http", {
|
|
245
101
|
allowed: !0,
|
|
246
102
|
failOpenReason: "store-error",
|
|
247
103
|
remaining: 0,
|
|
248
|
-
resetAtMs:
|
|
104
|
+
resetAtMs: s,
|
|
249
105
|
retryAfterMs: 0
|
|
250
|
-
}),
|
|
106
|
+
}), w.warn("http shield store failure — request ALLOWED without a limit check", { cause: String(t) }), e();
|
|
251
107
|
}
|
|
252
|
-
|
|
253
|
-
let
|
|
254
|
-
return
|
|
108
|
+
T.record("http", c);
|
|
109
|
+
let l = String(Math.max(0, Math.ceil((c.resetAtMs - s) / 1e3)));
|
|
110
|
+
return c.allowed ? e() : {
|
|
255
111
|
status: 429,
|
|
256
112
|
body: "Too Many Requests",
|
|
257
113
|
headers: {
|
|
258
114
|
"content-type": "text/plain; charset=utf-8",
|
|
259
|
-
"retry-after": String(Math.max(1, Math.ceil(
|
|
115
|
+
"retry-after": String(Math.max(1, Math.ceil(c.retryAfterMs / 1e3))),
|
|
260
116
|
"ratelimit-limit": String(a.limit),
|
|
261
|
-
"ratelimit-remaining": String(Math.max(0,
|
|
262
|
-
"ratelimit-reset":
|
|
117
|
+
"ratelimit-remaining": String(Math.max(0, c.remaining)),
|
|
118
|
+
"ratelimit-reset": l
|
|
263
119
|
}
|
|
264
120
|
};
|
|
265
|
-
} : void 0,
|
|
121
|
+
} : void 0, j = [
|
|
266
122
|
"inspect:read",
|
|
267
123
|
"rpc:intercept:mutation",
|
|
268
124
|
"rpc:intercept:query",
|
|
269
125
|
"rpc:intercept:action",
|
|
270
126
|
"rpc:intercept:workflow",
|
|
271
|
-
|
|
272
|
-
...
|
|
273
|
-
],
|
|
274
|
-
return
|
|
275
|
-
name:
|
|
127
|
+
"http:intercept",
|
|
128
|
+
...t.store === "postgres" ? ["store:write"] : []
|
|
129
|
+
], M, N;
|
|
130
|
+
return y({
|
|
131
|
+
name: b({
|
|
276
132
|
base: "@voltro/plugin-ratelimit",
|
|
277
|
-
instance:
|
|
133
|
+
instance: t.name
|
|
278
134
|
}),
|
|
279
135
|
description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
|
|
280
|
-
permissions:
|
|
136
|
+
permissions: j,
|
|
281
137
|
inspectEndpoints: [{
|
|
282
138
|
method: "GET",
|
|
283
139
|
path: "/state",
|
|
284
140
|
scope: "process",
|
|
285
141
|
description: "Declared rules and process-local limiting evidence, without bucket identities.",
|
|
286
|
-
handler: () =>
|
|
142
|
+
handler: () => m.sync(() => ({
|
|
287
143
|
kind: "json",
|
|
288
|
-
data:
|
|
144
|
+
data: T.snapshot()
|
|
289
145
|
}))
|
|
290
146
|
}],
|
|
291
147
|
declaredEnv: [
|
|
@@ -320,7 +176,7 @@ var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "f
|
|
|
320
176
|
description: "Fallback Upstash REST token for the http redis driver."
|
|
321
177
|
}
|
|
322
178
|
],
|
|
323
|
-
...
|
|
179
|
+
...A ? { onHttpRequest: A } : {},
|
|
324
180
|
errorSchemas: [{
|
|
325
181
|
schema: e,
|
|
326
182
|
import: {
|
|
@@ -329,34 +185,47 @@ var f = (e) => typeof e == "function" ? "dynamic" : e, p = (e) => typeof e == "f
|
|
|
329
185
|
},
|
|
330
186
|
rest: {
|
|
331
187
|
status: 429,
|
|
332
|
-
headers:
|
|
188
|
+
headers: C
|
|
333
189
|
}
|
|
334
190
|
}],
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
191
|
+
restRateLimit: (t, n, r) => k(m.void, {
|
|
192
|
+
tag: `${r.method} ${r.path}`,
|
|
193
|
+
kind: "action",
|
|
194
|
+
input: void 0,
|
|
195
|
+
subject: n.subject,
|
|
196
|
+
traceId: _(),
|
|
197
|
+
surface: "rest",
|
|
198
|
+
publicApi: { rateLimit: t },
|
|
199
|
+
...n.remoteAddr === void 0 ? {} : { remoteAddr: n.remoteAddr }
|
|
200
|
+
}).pipe(m.catch((t) => m.fail(t instanceof e ? h(429, {
|
|
201
|
+
error: "RateLimited",
|
|
202
|
+
message: "Rate limit exceeded"
|
|
203
|
+
}, C(t)) : t))),
|
|
204
|
+
interceptMutation: k,
|
|
205
|
+
interceptQuery: k,
|
|
206
|
+
interceptAction: k,
|
|
207
|
+
interceptWorkflow: k,
|
|
208
|
+
...t.store === "postgres" ? { extendSchema: { tables: [s] } } : {},
|
|
340
209
|
bindDataStore: (e) => {
|
|
341
|
-
|
|
342
|
-
|
|
210
|
+
g.bind && (T.binding("binding"), g.bind(e).then(() => {
|
|
211
|
+
N = void 0, T.binding("bound");
|
|
343
212
|
}).catch((e) => {
|
|
344
|
-
|
|
213
|
+
T.binding("failed"), N = e;
|
|
345
214
|
}));
|
|
346
215
|
},
|
|
347
|
-
onActivate: (e) =>
|
|
348
|
-
|
|
349
|
-
let
|
|
350
|
-
|
|
216
|
+
onActivate: (e) => m.gen(function* () {
|
|
217
|
+
g.activate && (yield* m.promise(() => g.activate(e.env))), M = setInterval(() => g.sweep(Date.now()), 6e4), M.unref?.(), N !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: N instanceof Error ? N.message : String(N) });
|
|
218
|
+
let n = t.store === void 0 || t.store === "memory" ? v(e.env) : null;
|
|
219
|
+
n !== null && e.logger.warn(`rate-limit: the counter store is PROCESS-LOCAL (the default), but ${n}, 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", {
|
|
351
220
|
rules: a.length,
|
|
352
|
-
hasDefault:
|
|
353
|
-
store: typeof
|
|
221
|
+
hasDefault: t.default !== void 0,
|
|
222
|
+
store: typeof t.store == "string" ? t.store : t.store === void 0 ? "memory" : "custom"
|
|
354
223
|
});
|
|
355
224
|
}),
|
|
356
|
-
onDeactivate: () =>
|
|
357
|
-
|
|
225
|
+
onDeactivate: () => m.gen(function* () {
|
|
226
|
+
M && clearInterval(M), g.deactivate && (yield* m.promise(() => g.deactivate()));
|
|
358
227
|
})
|
|
359
228
|
});
|
|
360
229
|
};
|
|
361
230
|
//#endregion
|
|
362
|
-
export { a as RATELIMIT_TABLE, e as RateLimited,
|
|
231
|
+
export { a as RATELIMIT_TABLE, e as RateLimited, t as consumeBucket, i as memoryStore, n as parseWindow, o as postgresStore, T as rateLimitPlugin, s as rateLimitTable, c as redisStore };
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parseWindow as e } from "./window.js";
|
|
2
|
+
//#region src/inspect.ts
|
|
3
|
+
var t = (e) => typeof e == "function" ? "dynamic" : e, n = (e) => typeof e == "function" ? "dynamic" : e, r = (r, i) => ({
|
|
4
|
+
id: i === null ? "default" : `r${i}`,
|
|
5
|
+
match: "match" in r ? r.match instanceof RegExp ? String(r.match) : Array.isArray(r.match) ? r.match.join(", ") : r.match ?? "*" : "*",
|
|
6
|
+
kinds: r.kind === void 0 ? [] : typeof r.kind == "string" ? [r.kind] : r.kind,
|
|
7
|
+
limit: t(r.limit),
|
|
8
|
+
windowMs: typeof r.window == "function" ? "dynamic" : e(r.window),
|
|
9
|
+
algorithm: r.algorithm ?? "sliding-window",
|
|
10
|
+
burst: r.burst === void 0 ? t(r.limit) : t(r.burst),
|
|
11
|
+
by: Array.isArray(r.by) ? r.by.map(n) : [n(r.by ?? "subject")],
|
|
12
|
+
scope: r.scope ?? "tag",
|
|
13
|
+
tenantOverrides: Object.keys(r.tenants ?? {}).length
|
|
14
|
+
}), i = () => ({
|
|
15
|
+
allowed: 0,
|
|
16
|
+
rejected: 0,
|
|
17
|
+
bypassed: 0,
|
|
18
|
+
unbound: 0,
|
|
19
|
+
storeErrors: 0,
|
|
20
|
+
lastRejectedAt: null,
|
|
21
|
+
lastStoreErrorAt: null
|
|
22
|
+
}), a = (t, n) => {
|
|
23
|
+
let a = Date.now(), o = {
|
|
24
|
+
rpc: i(),
|
|
25
|
+
http: i()
|
|
26
|
+
}, s = n.bind ? "waiting" : "not-required";
|
|
27
|
+
return {
|
|
28
|
+
binding: (e) => {
|
|
29
|
+
s = e;
|
|
30
|
+
},
|
|
31
|
+
bypass: () => {
|
|
32
|
+
o.rpc.bypassed++;
|
|
33
|
+
},
|
|
34
|
+
record: (e, t) => {
|
|
35
|
+
let n = o[e];
|
|
36
|
+
t.failOpenReason === "unbound" ? n.unbound++ : t.failOpenReason === "store-error" ? (n.storeErrors++, n.lastStoreErrorAt = Date.now()) : t.allowed ? n.allowed++ : (n.rejected++, n.lastRejectedAt = Date.now());
|
|
37
|
+
},
|
|
38
|
+
snapshot: () => {
|
|
39
|
+
let i = null;
|
|
40
|
+
try {
|
|
41
|
+
let e = n.size();
|
|
42
|
+
Number.isFinite(e) && e >= 0 && (i = e);
|
|
43
|
+
} catch {}
|
|
44
|
+
let c = typeof t.store == "string" ? t.store : t.store === void 0 ? "memory" : "custom";
|
|
45
|
+
return {
|
|
46
|
+
capturedAt: Date.now(),
|
|
47
|
+
startedAt: a,
|
|
48
|
+
rpcGate: t.enabled === !1 ? "disabled" : typeof t.enabled == "function" ? "dynamic" : "enabled",
|
|
49
|
+
dynamicResolver: t.resolve !== void 0,
|
|
50
|
+
store: {
|
|
51
|
+
kind: c,
|
|
52
|
+
scope: c === "memory" ? "process" : c === "custom" ? "unknown" : "shared-store",
|
|
53
|
+
sqlBinding: s,
|
|
54
|
+
trackedBuckets: i
|
|
55
|
+
},
|
|
56
|
+
rules: (t.rules ?? []).map((e, t) => r(e, t)),
|
|
57
|
+
defaultRule: t.default ? r(t.default, null) : null,
|
|
58
|
+
http: t.http ? {
|
|
59
|
+
limit: t.http.limit,
|
|
60
|
+
windowMs: e(t.http.window),
|
|
61
|
+
algorithm: t.http.algorithm ?? "fixed-window"
|
|
62
|
+
} : null,
|
|
63
|
+
counters: {
|
|
64
|
+
rpc: { ...o.rpc },
|
|
65
|
+
http: { ...o.http }
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
export { a as makeRateLimitInspect };
|
package/dist/postgresStore.js
CHANGED
|
@@ -1,2 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { consumeBucket as e } from "./window.js";
|
|
2
|
+
import { Effect as t } from "effect";
|
|
3
|
+
import { dropped as n, eq as r, id as i, json as a, lt as o, queryFor as s, table as c, text as l, timestamp as u } from "@voltro/database";
|
|
4
|
+
//#region src/postgresStore.ts
|
|
5
|
+
var d = "_voltro_ratelimit", f = c(d, {
|
|
6
|
+
id: i({ scheme: "numeric" }),
|
|
7
|
+
key: l().maxLength(512),
|
|
8
|
+
state: a().nullable(),
|
|
9
|
+
touchedAt: u().default("now"),
|
|
10
|
+
touched: n()
|
|
11
|
+
}).unique("rateLimitKey", ["key"]).nonReactive(), p = (n = {}) => {
|
|
12
|
+
let i = n.staleAfterMs ?? 36e5, a = null, c = (e, t, n) => ({
|
|
13
|
+
failOpenReason: n,
|
|
14
|
+
allowed: !0,
|
|
15
|
+
remaining: e.limit,
|
|
16
|
+
resetAtMs: t + e.windowMs,
|
|
17
|
+
retryAfterMs: 0
|
|
18
|
+
}), l = async (t, n, i, a) => {
|
|
19
|
+
let o = new Date(a);
|
|
20
|
+
return await t.insertIgnore(d, {
|
|
21
|
+
key: n,
|
|
22
|
+
state: null,
|
|
23
|
+
touchedAt: o
|
|
24
|
+
}, { conflictColumns: ["key"] }), t.transactional(async (t) => {
|
|
25
|
+
let c = (await t.query(s(f).where(r("key", n)).forUpdate().descriptor))[0]?.state ?? void 0, { state: l, decision: u } = e(c ?? void 0, i, a);
|
|
26
|
+
return await t.updateMany(d, {
|
|
27
|
+
state: l,
|
|
28
|
+
touchedAt: o
|
|
29
|
+
}, { where: r("key", n) }), u;
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
consume: (e, n, r) => {
|
|
34
|
+
let i = a;
|
|
35
|
+
return i ? t.tryPromise(() => l(i, e, n, r)).pipe(t.catch(() => t.succeed(c(n, r, "store-error")))) : t.succeed(c(n, r, "unbound"));
|
|
36
|
+
},
|
|
37
|
+
sweep: (e) => {
|
|
38
|
+
let t = a;
|
|
39
|
+
t && t.deleteMany(d, { where: o("touchedAt", new Date(e - i)) }).catch(() => {});
|
|
40
|
+
},
|
|
41
|
+
size: () => -1,
|
|
42
|
+
bind: async (e) => {
|
|
43
|
+
a = e;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { d as RATELIMIT_TABLE, p as postgresStore, f as rateLimitTable };
|
package/dist/redisStore.js
CHANGED
|
@@ -24,7 +24,7 @@ var n = "\nlocal key = KEYS[1]\nlocal algo = ARGV[1]\nlocal limit = tonumber(ARG
|
|
|
24
24
|
return c ? e.tryPromise(async () => {
|
|
25
25
|
let e = await c.eval(n, 1, `${i}${t}`, r.algorithm, r.limit, r.windowMs, r.burst, o);
|
|
26
26
|
return JSON.parse(String(e));
|
|
27
|
-
}).pipe(e.
|
|
27
|
+
}).pipe(e.catch(() => e.succeed(s(r, o, "store-error")))) : e.succeed(s(r, o, "unbound"));
|
|
28
28
|
};
|
|
29
29
|
return {
|
|
30
30
|
consume: (t, n, r) => a ? d(t, n, r) : l(process.env) === void 0 ? e.succeed(s(n, r, "unbound")) : e.tryPromise(() => u(process.env)).pipe(e.matchEffect({
|