@voltro/plugin-ratelimit 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.
@@ -0,0 +1,36 @@
1
+ import { Schema } from 'effect';
2
+
3
+ /**
4
+ * Raised on the RPC error channel when a call exceeds its limit. The
5
+ * interceptor fails with this; the framework's rpc encoder carries it.
6
+ *
7
+ * The plugin registers this schema through `errorSchemas` (see
8
+ * `index.ts`), so it is merged into EVERY procedure's wire error union —
9
+ * the client decodes it as a typed `RateLimited` instance (catchable via
10
+ * `Effect.catchTag('RateLimited', …)` or `instanceof RateLimited`) on any
11
+ * limited call, with no per-route `error:` opt-in required.
12
+ *
13
+ * Import it from the browser-safe `@voltro/plugin-ratelimit/errors`
14
+ * subpath when a descriptor names it in its own `error:` schema — this
15
+ * file imports only `effect` (no server modules), so it's safe in a
16
+ * client bundle.
17
+ */
18
+ export declare class RateLimited extends RateLimited_base {
19
+ }
20
+
21
+ declare const RateLimited_base: Schema.TaggedErrorClass<RateLimited, "RateLimited", {
22
+ readonly _tag: Schema.tag<"RateLimited">;
23
+ } & {
24
+ /** The rpc tag that was limited, e.g. `'todos.create'`. */
25
+ tag: typeof Schema.String;
26
+ /** The effective limit that was hit. */
27
+ limit: typeof Schema.Number;
28
+ /** The window the limit applies over, in ms. */
29
+ windowMs: typeof Schema.Number;
30
+ /** Suggested wait before retrying, in ms. */
31
+ retryAfterMs: typeof Schema.Number;
32
+ /** Epoch-ms when the bucket is expected to be replenished. */
33
+ resetAtMs: typeof Schema.Number;
34
+ }>;
35
+
36
+ export { }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ import { Schema as e } from "effect";
2
+ //#region src/errors.ts
3
+ var t = class extends e.TaggedError()("RateLimited", {
4
+ tag: e.String,
5
+ limit: e.Number,
6
+ windowMs: e.Number,
7
+ retryAfterMs: e.Number,
8
+ resetAtMs: e.Number
9
+ }) {};
10
+ //#endregion
11
+ export { t as RateLimited };
@@ -0,0 +1,319 @@
1
+ import { Effect } from 'effect';
2
+ import { RespClient } from '@voltro/kv/connection';
3
+ import { RpcKind } from '@voltro/protocol';
4
+ import { Schema } from 'effect';
5
+ import { SqlClient } from '@effect/sql';
6
+ import { Subject } from '@voltro/protocol';
7
+ import { VoltroPlugin } from '@voltro/protocol';
8
+
9
+ export declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
10
+
11
+ declare interface BucketResult {
12
+ readonly state: BucketState;
13
+ readonly decision: RateLimitDecision;
14
+ }
15
+
16
+ /** Opaque per-key state. The store persists it verbatim; only this
17
+ * module reads its shape. */
18
+ export declare type BucketState = {
19
+ readonly kind: 'fixed';
20
+ readonly windowStart: number;
21
+ readonly count: number;
22
+ } | {
23
+ readonly kind: 'sliding';
24
+ readonly currStart: number;
25
+ readonly currCount: number;
26
+ readonly prevCount: number;
27
+ } | {
28
+ readonly kind: 'token';
29
+ readonly tokens: number;
30
+ readonly lastRefill: number;
31
+ };
32
+
33
+ export declare const consumeBucket: (prev: BucketState | undefined, limit: ResolvedLimit, now: number) => BucketResult;
34
+
35
+ /** The fallback rule applied when no `rules` entry matches. Same shape
36
+ * minus `match` (it matches whatever's left). Omit to leave unmatched
37
+ * calls unlimited. */
38
+ export declare type DefaultRule = Omit<RateLimitRule, 'match'>;
39
+
40
+ /** A value that can be a constant or computed per-call. Dynamic form
41
+ * receives the resolution context (subject, tenant, tag). */
42
+ export declare type Dynamic<T> = T | ((ctx: RateLimitContext) => T);
43
+
44
+ /** Coarse pre-auth IP shield on the HTTP pipeline. */
45
+ export declare interface HttpShieldOptions {
46
+ readonly limit: number;
47
+ readonly window: WindowSpec;
48
+ /** Default `'fixed-window'` — cheapest for a front-door guard. */
49
+ readonly algorithm?: Algorithm;
50
+ }
51
+
52
+ /** What to key the bucket on. Composite arrays join into one key, so
53
+ * `['tenant', 'subject']` = one bucket per (tenant, user). */
54
+ export declare type KeyBy = 'subject' | 'tenant' | 'apiKey' | 'global' | ((ctx: RateLimitContext) => string);
55
+
56
+ /** Diagnostics passed to the optional `onLimited` callback. */
57
+ export declare interface LimitedInfo {
58
+ readonly tag: string;
59
+ readonly kind: RpcKind;
60
+ readonly key: string;
61
+ readonly tenantId: string | null;
62
+ readonly limit: number;
63
+ readonly windowMs: number;
64
+ readonly retryAfterMs: number;
65
+ }
66
+
67
+ export declare const memoryStore: (options?: MemoryStoreOptions) => RateLimitStore;
68
+
69
+ export declare interface MemoryStoreOptions {
70
+ /** Hard cap on tracked keys. Oldest-touched entries are evicted past
71
+ * this. Default 100k — a runaway-key backstop, not a tuning knob. */
72
+ readonly maxKeys?: number;
73
+ /** Entries untouched for longer than this (ms) are dropped on sweep.
74
+ * Default 1h. */
75
+ readonly staleAfterMs?: number;
76
+ }
77
+
78
+ /** Parse a `WindowSpec` to milliseconds. Throws on malformed input — a
79
+ * bad window is a config error that should surface loudly at boot. */
80
+ export declare const parseWindow: (spec: WindowSpec) => number;
81
+
82
+ export declare interface PostgresStoreOptions {
83
+ /** Table name. Default `_voltro_ratelimit`. */
84
+ readonly table?: string;
85
+ /** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
86
+ readonly staleAfterMs?: number;
87
+ }
88
+
89
+ /** The context handed to every dynamic option + the `resolve` hook.
90
+ * Derived from the framework's RpcInterceptorContext. */
91
+ export declare interface RateLimitContext {
92
+ readonly tag: string;
93
+ readonly kind: RpcKind;
94
+ readonly subject: Subject;
95
+ /** Convenience: the subject's tenant (`null` only for unscoped anonymous). */
96
+ readonly tenantId: string | null;
97
+ readonly traceId: string;
98
+ }
99
+
100
+ export declare interface RateLimitDecision {
101
+ readonly allowed: boolean;
102
+ /** Remaining allowance in the current window (best-effort estimate). */
103
+ readonly remaining: number;
104
+ /** Epoch-ms when the bucket is expected to be fully replenished. */
105
+ readonly resetAtMs: number;
106
+ /** How long the caller should wait before retrying. 0 when allowed. */
107
+ readonly retryAfterMs: number;
108
+ }
109
+
110
+ /**
111
+ * Raised on the RPC error channel when a call exceeds its limit. The
112
+ * interceptor fails with this; the framework's rpc encoder carries it.
113
+ *
114
+ * The plugin registers this schema through `errorSchemas` (see
115
+ * `index.ts`), so it is merged into EVERY procedure's wire error union —
116
+ * the client decodes it as a typed `RateLimited` instance (catchable via
117
+ * `Effect.catchTag('RateLimited', …)` or `instanceof RateLimited`) on any
118
+ * limited call, with no per-route `error:` opt-in required.
119
+ *
120
+ * Import it from the browser-safe `@voltro/plugin-ratelimit/errors`
121
+ * subpath when a descriptor names it in its own `error:` schema — this
122
+ * file imports only `effect` (no server modules), so it's safe in a
123
+ * client bundle.
124
+ */
125
+ export declare class RateLimited extends RateLimited_base {
126
+ }
127
+
128
+ declare const RateLimited_base: Schema.TaggedErrorClass<RateLimited, "RateLimited", {
129
+ readonly _tag: Schema.tag<"RateLimited">;
130
+ } & {
131
+ /** The rpc tag that was limited, e.g. `'todos.create'`. */
132
+ tag: typeof Schema.String;
133
+ /** The effective limit that was hit. */
134
+ limit: typeof Schema.Number;
135
+ /** The window the limit applies over, in ms. */
136
+ windowMs: typeof Schema.Number;
137
+ /** Suggested wait before retrying, in ms. */
138
+ retryAfterMs: typeof Schema.Number;
139
+ /** Epoch-ms when the bucket is expected to be replenished. */
140
+ resetAtMs: typeof Schema.Number;
141
+ }>;
142
+
143
+ export declare const rateLimitPlugin: (options?: RateLimitPluginOptions) => VoltroPlugin;
144
+
145
+ export declare interface RateLimitPluginOptions {
146
+ /** Ordered, per-endpoint rules. First match wins. */
147
+ readonly rules?: ReadonlyArray<RateLimitRule>;
148
+ /**
149
+ * Coarse PRE-AUTH IP shield, keyed by remote IP. Fires on EVERY HTTP
150
+ * request — inspect, webhooks, AND the rpc websocket upgrade — before
151
+ * any subject is resolved. A cheap DoS front-door in addition to the
152
+ * per-subject rpc `rules`. Shares the same `store`. Omit to disable.
153
+ * Requires the `http:intercept` permission, which the plugin declares
154
+ * automatically when this is set.
155
+ */
156
+ readonly http?: HttpShieldOptions;
157
+ /** Fallback for calls no rule matched. Omit → unmatched calls pass. */
158
+ readonly default?: DefaultRule;
159
+ /**
160
+ * Counter backend:
161
+ * - `'memory'` (default) — single-node, zero-config.
162
+ * - `'postgres'` — multi-node correct; opens its own pool from the
163
+ * same `DB_*`/`PG_*` env the framework reads, creates
164
+ * `_voltro_ratelimit`, mutates state under a row lock. Fails OPEN
165
+ * on db errors (degrades to no-limit, never to 500s).
166
+ * - `'redis'` — multi-node, fastest; connects ioredis from
167
+ * `CACHE_REDIS_URL`/`REDIS_URL`, runs the whole decision in one
168
+ * atomic Lua script. For a custom client/url use the
169
+ * `redisStore({...})` factory from `@voltro/plugin-ratelimit/redis`.
170
+ * - a custom `RateLimitStore`.
171
+ */
172
+ readonly store?: 'memory' | 'postgres' | 'redis' | RateLimitStore;
173
+ /**
174
+ * Master gate. `false` disables all limiting; a function lets you gate
175
+ * per-call (e.g. exempt a tenant, or a maintenance bypass). Returning
176
+ * `false` means "no limit for this call". Default: enabled.
177
+ */
178
+ readonly enabled?: boolean | ((ctx: RateLimitContext) => boolean);
179
+ /**
180
+ * Fully-programmatic per-tenant resolution. Runs AFTER a rule + its
181
+ * static tenant override are resolved; whatever it returns wins. Use
182
+ * for limits sourced from a tenant's plan/tier (e.g. read
183
+ * `subject.metadata.plan`, or look the tenant up — return an Effect).
184
+ *
185
+ * - return a `ResolvedRuleOverride` → override this call's limit
186
+ * - return `'exempt'` → skip limiting this call
187
+ * - return `undefined` → keep the rule-derived limit
188
+ *
189
+ * Sync, Promise, or Effect — all three are normalised.
190
+ */
191
+ readonly resolve?: (ctx: RateLimitContext, base: ResolvedRuleOverride | undefined) => ResolvedRuleOverride | 'exempt' | undefined | Promise<ResolvedRuleOverride | 'exempt' | undefined> | Effect.Effect<ResolvedRuleOverride | 'exempt' | undefined>;
192
+ /** Fired (best-effort, swallowed on throw) whenever a call is rejected. */
193
+ readonly onLimited?: (info: LimitedInfo) => void;
194
+ /** Disambiguates multiple instances of this plugin in one app. */
195
+ readonly name?: string;
196
+ }
197
+
198
+ /** One rate-limit rule. Rules are evaluated top-to-bottom; the first
199
+ * whose `match` + `kind` accept the call wins. */
200
+ export declare interface RateLimitRule {
201
+ /**
202
+ * Which endpoints this rule covers:
203
+ * - omitted → every tag (subject to `kind`)
204
+ * - string → exact rpc tag, e.g. `'todos.create'`
205
+ * - string[] → any of these exact tags
206
+ * - RegExp → tags matching the pattern, e.g. `/^admin\./`
207
+ */
208
+ readonly match?: string | ReadonlyArray<string> | RegExp;
209
+ /** Restrict to certain rpc kinds (limit writes but not reads, etc.).
210
+ * Omitted → all kinds. */
211
+ readonly kind?: RpcKind | ReadonlyArray<RpcKind>;
212
+ /** Allowance per window. Static, or `(ctx) => n` for per-tenant/plan logic. */
213
+ readonly limit: Dynamic<number>;
214
+ /** Window length. Static or dynamic. */
215
+ readonly window: Dynamic<WindowSpec>;
216
+ /** Algorithm. Default `'sliding-window'`. */
217
+ readonly algorithm?: Algorithm;
218
+ /** Token-bucket burst capacity. Defaults to `limit`. Ignored by the
219
+ * window algorithms. */
220
+ readonly burst?: Dynamic<number>;
221
+ /** Keying dimension(s). Default `'subject'`. */
222
+ readonly by?: KeyBy | ReadonlyArray<KeyBy>;
223
+ /**
224
+ * Bucket granularity across the tags this rule matches:
225
+ * - `'tag'` (default) — a separate bucket per matched endpoint, so
226
+ * `todos.create` and `todos.update` count independently.
227
+ * - `'rule'` — one shared bucket across ALL tags the rule matches
228
+ * (e.g. "200 writes/min total across every mutation").
229
+ */
230
+ readonly scope?: 'tag' | 'rule';
231
+ /** Static per-tenant overrides, keyed by tenantId. */
232
+ readonly tenants?: Readonly<Record<string, TenantOverride>>;
233
+ }
234
+
235
+ export declare interface RateLimitStore {
236
+ /** Atomically consume one unit for `key` against `limit` at `nowMs`. */
237
+ readonly consume: (key: string, limit: ResolvedLimit, nowMs: number) => Effect.Effect<RateLimitDecision>;
238
+ /** Drop entries untouched long enough to be irrelevant. Called on a timer. */
239
+ readonly sweep: (nowMs: number) => void;
240
+ /** Current number of tracked keys (diagnostics / tests). `-1` when the
241
+ * store can't answer synchronously (e.g. postgres). */
242
+ readonly size: () => number;
243
+ /** Optional one-shot setup at plugin activate (open pools, create
244
+ * tables). Receives the process env. */
245
+ readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
246
+ /** Optional teardown at plugin deactivate (close pools). */
247
+ readonly deactivate?: () => Promise<void>;
248
+ /** Optional bind of the framework's already-open `SqlClient` (from the
249
+ * plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
250
+ * runs through THIS client instead of standing up its own pool; creates
251
+ * its bookkeeping table here. Not present on memory / redis stores. */
252
+ readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
253
+ }
254
+
255
+ export declare const redisStore: (options?: RedisStoreOptions) => RateLimitStore;
256
+
257
+ export declare interface RedisStoreOptions {
258
+ /** Connection url. `resp` driver: `redis://…` / `rediss://…`. `http`
259
+ * driver: the Upstash REST url. Falls back to `CACHE_REDIS_URL` /
260
+ * `REDIS_URL` env at activate time. */
261
+ readonly url?: string;
262
+ /**
263
+ * How to talk to the server — mirrors the cache plugin's axis:
264
+ * - `'resp'` (default) — ioredis over TCP. Works for Redis, Valkey,
265
+ * KeyDB, Dragonfly, and Upstash's TCP endpoint.
266
+ * - `'http'` — `@upstash/redis` over REST, for serverless/edge where
267
+ * TCP isn't available.
268
+ * Resolved from `CACHE_REDIS_DRIVER` env when omitted. `'redis'` as a
269
+ * store value is the umbrella for the whole RESP family — the server
270
+ * brand is just the url; only the DRIVER is a real choice.
271
+ */
272
+ readonly driver?: 'resp' | 'http';
273
+ /** Auth token — `http` (Upstash REST) driver only. Falls back to
274
+ * `CACHE_REDIS_TOKEN` / `UPSTASH_REDIS_REST_TOKEN` env. */
275
+ readonly token?: string;
276
+ /** Bring your own already-connected RESP client (from
277
+ * `@voltro/kv/connection`'s `connect`, or the shared registry). Takes
278
+ * precedence over `url` / `driver`. */
279
+ readonly client?: RespClient;
280
+ /** Key namespace. Default `'voltro:rl:'`. */
281
+ readonly keyPrefix?: string;
282
+ }
283
+
284
+ /** The fully-resolved limit the store consumes against. All dynamic /
285
+ * per-tenant resolution has already collapsed into these scalars. */
286
+ export declare interface ResolvedLimit {
287
+ readonly limit: number;
288
+ readonly windowMs: number;
289
+ readonly algorithm: Algorithm;
290
+ /** Token-bucket capacity. Equals `limit` unless an explicit burst was set. */
291
+ readonly burst: number;
292
+ }
293
+
294
+ /** The limit shape the `resolve` hook returns / receives — scalars only,
295
+ * no dynamic wrappers (resolution already happened). */
296
+ export declare interface ResolvedRuleOverride {
297
+ readonly limit: number;
298
+ readonly window: WindowSpec;
299
+ readonly algorithm?: Algorithm;
300
+ readonly burst?: number;
301
+ }
302
+
303
+ /** Per-tenant override applied on top of a rule. Any field omitted
304
+ * inherits the rule's value. `disabled` exempts the tenant entirely. */
305
+ export declare interface TenantOverride {
306
+ readonly limit?: Dynamic<number>;
307
+ readonly window?: Dynamic<WindowSpec>;
308
+ readonly algorithm?: Algorithm;
309
+ readonly burst?: Dynamic<number>;
310
+ readonly disabled?: boolean;
311
+ }
312
+
313
+ export { VoltroPlugin }
314
+
315
+ /** A window length. Either milliseconds as a number, or a unit string
316
+ * like `'1m'`, `'10s'`, `'500ms'`, `'2h'`, `'1d'`. */
317
+ export declare type WindowSpec = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` | number;
318
+
319
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,243 @@
1
+ import { RateLimited as e } from "./errors.js";
2
+ import { n as t, t as n } from "./window-lyQ6LXhj.js";
3
+ import { redisStore as r } from "./redisStore.js";
4
+ import { Effect as i } from "effect";
5
+ import { definePlugin as a } from "@voltro/protocol";
6
+ //#region src/store.ts
7
+ var o = (e = {}) => {
8
+ let t = e.maxKeys ?? 1e5, r = e.staleAfterMs ?? 36e5, a = /* @__PURE__ */ new Map(), o = (e) => {
9
+ if (a.size <= t) return;
10
+ let n = a.size - t, r = 0;
11
+ for (let t of a.keys()) {
12
+ if (r >= n) break;
13
+ t !== e && (a.delete(t), r += 1);
14
+ }
15
+ };
16
+ return {
17
+ consume: (e, t, r) => i.sync(() => {
18
+ let { state: i, decision: s } = n(a.get(e)?.state, t, r);
19
+ return a.set(e, {
20
+ state: i,
21
+ touched: r
22
+ }), o(e), s;
23
+ }),
24
+ sweep: (e) => {
25
+ let t = e - r;
26
+ for (let [e, n] of a) n.touched < t && a.delete(e);
27
+ },
28
+ size: () => a.size
29
+ };
30
+ }, s = (e, t) => ({
31
+ allowed: !0,
32
+ remaining: e.limit,
33
+ resetAtMs: t + e.windowMs,
34
+ retryAfterMs: 0
35
+ }), c = (e = {}) => {
36
+ let t = null;
37
+ return {
38
+ consume: (e, n, r) => t ? t.consume(e, n, r) : i.succeed(s(n, r)),
39
+ sweep: (e) => t?.sweep(e),
40
+ size: () => t ? t.size() : -1,
41
+ bindSql: async (n) => {
42
+ let { postgresStore: r } = await import("./postgresStore.js"), i = r(e);
43
+ await i.bindSql(n), t = i;
44
+ }
45
+ };
46
+ }, l = (e, t) => typeof e == "function" ? e(t) : e, u = (e, t) => e === void 0 ? !0 : typeof e == "string" ? e === t : Array.isArray(e) ? e.includes(t) : e.test(t), d = (e, t) => e === void 0 ? !0 : Array.isArray(e) ? e.includes(t) : e === t, f = (e, t) => {
47
+ let n = t === null ? void 0 : e.tenants?.[t];
48
+ return {
49
+ limit: n?.limit ?? e.limit,
50
+ window: n?.window ?? e.window,
51
+ algorithm: n?.algorithm ?? e.algorithm,
52
+ burst: n?.burst ?? e.burst,
53
+ by: e.by ?? "subject",
54
+ scope: e.scope ?? "tag",
55
+ disabled: n?.disabled ?? !1
56
+ };
57
+ }, p = (e, t, n) => {
58
+ for (let t = 0; t < e.length; t++) {
59
+ let r = e[t];
60
+ if (u(r.match, n.tag) && d(r.kind, n.kind)) return {
61
+ effective: f(r, n.tenantId),
62
+ ruleId: `r${t}`
63
+ };
64
+ }
65
+ if (t && d(t.kind, n.kind)) return {
66
+ effective: f(t, n.tenantId),
67
+ ruleId: "default"
68
+ };
69
+ }, m = (e, n) => {
70
+ let r = l(e.limit, n);
71
+ return {
72
+ limit: r,
73
+ windowMs: t(l(e.window, n)),
74
+ algorithm: e.algorithm ?? "sliding-window",
75
+ burst: e.burst === void 0 ? r : l(e.burst, n)
76
+ };
77
+ }, h = (e) => {
78
+ let n = e.limit;
79
+ return {
80
+ limit: n,
81
+ windowMs: t(e.window),
82
+ algorithm: e.algorithm ?? "sliding-window",
83
+ burst: e.burst ?? n
84
+ };
85
+ }, g = (e) => ({
86
+ limit: e.limit,
87
+ window: e.windowMs,
88
+ algorithm: e.algorithm,
89
+ burst: e.burst
90
+ }), _ = (e, t) => {
91
+ if (typeof e == "function") return e(t);
92
+ switch (e) {
93
+ case "subject": return `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
94
+ case "tenant": return `t:${t.tenantId ?? "∅"}`;
95
+ case "apiKey": return t.subject.type === "apiKey" ? `k:${t.subject.id}` : `s:${t.tenantId ?? "∅"}:${t.subject.id ?? "anon"}`;
96
+ case "global": return "g";
97
+ }
98
+ }, v = (e, t) => {
99
+ let { effective: n, ruleId: r } = e, i = n.scope === "rule" ? `rule#${r}` : `tag:${t.tag}`, a = n.by;
100
+ return `${i}|${Array.isArray(a) ? a.map((e) => _(e, t)).join("|") : _(a, t)}`;
101
+ }, y = (e) => ({
102
+ tag: e.tag,
103
+ kind: e.kind,
104
+ subject: e.subject,
105
+ tenantId: e.subject.tenantId ?? null,
106
+ traceId: e.traceId
107
+ }), b = (n = {}) => {
108
+ let s = n.rules ?? [], l = n.store === void 0 || n.store === "memory" ? o() : n.store === "postgres" ? c() : n.store === "redis" ? r() : n.store, u = (e) => {
109
+ let t = n.enabled;
110
+ return t === void 0 ? !0 : typeof t == "function" ? t(e) : t;
111
+ }, d = (e, t, r, i) => {
112
+ if (!n.onLimited) return;
113
+ let a = {
114
+ tag: e.tag,
115
+ kind: e.kind,
116
+ key: t,
117
+ tenantId: e.tenantId,
118
+ limit: r.limit,
119
+ windowMs: r.windowMs,
120
+ retryAfterMs: i.retryAfterMs
121
+ };
122
+ try {
123
+ n.onLimited(a);
124
+ } catch {}
125
+ }, f = (e, t) => {
126
+ let r = n.resolve;
127
+ return r ? i.suspend(() => {
128
+ let n = r(e, t);
129
+ return (i.isEffect(n) ? n : n instanceof Promise ? i.promise(() => n) : i.succeed(n)).pipe(i.map((e) => e === "exempt" ? "exempt" : h(e === void 0 ? t : e)));
130
+ }) : i.succeed(h(t));
131
+ }, _ = (t, r) => i.suspend(() => {
132
+ let a = y(r);
133
+ if (!u(a)) return t;
134
+ let o = p(s, n.default, a);
135
+ if (!o || o.effective.disabled) return t;
136
+ let c = g(m(o.effective, a));
137
+ return f(a, c).pipe(i.flatMap((n) => {
138
+ if (n === "exempt") return t;
139
+ let r = v(o, a);
140
+ return l.consume(r, n, Date.now()).pipe(i.catchAllDefect((e) => i.as(i.logWarning(`[ratelimit] store error — failing open for ${a.tag}: ${String(e)}`), {
141
+ allowed: !0,
142
+ remaining: n.limit,
143
+ resetAtMs: Date.now(),
144
+ retryAfterMs: 0
145
+ })), i.flatMap((o) => o.allowed ? t : i.zipRight(i.sync(() => d(a, r, n, o)), i.fail(new e({
146
+ tag: a.tag,
147
+ limit: n.limit,
148
+ windowMs: n.windowMs,
149
+ retryAfterMs: o.retryAfterMs,
150
+ resetAtMs: o.resetAtMs
151
+ })))));
152
+ }));
153
+ }), b = n.http ? async (e, r) => {
154
+ let a = n.http, o = {
155
+ limit: a.limit,
156
+ windowMs: t(a.window),
157
+ algorithm: a.algorithm ?? "fixed-window",
158
+ burst: a.limit
159
+ }, s = r.remoteAddr ?? "unknown", c = Date.now(), u = await i.runPromise(l.consume(`http:ip:${s}`, o, c)), d = String(Math.max(0, Math.ceil((u.resetAtMs - c) / 1e3)));
160
+ return u.allowed ? e() : {
161
+ status: 429,
162
+ body: "Too Many Requests",
163
+ headers: {
164
+ "content-type": "text/plain; charset=utf-8",
165
+ "retry-after": String(Math.max(1, Math.ceil(u.retryAfterMs / 1e3))),
166
+ "ratelimit-limit": String(o.limit),
167
+ "ratelimit-remaining": String(Math.max(0, u.remaining)),
168
+ "ratelimit-reset": d
169
+ }
170
+ };
171
+ } : void 0, x = [
172
+ "rpc:intercept:mutation",
173
+ "rpc:intercept:query",
174
+ "rpc:intercept:action",
175
+ ...n.http ? ["http:intercept"] : []
176
+ ], S, C;
177
+ return a({
178
+ name: n.name ? `@voltro/plugin-ratelimit#${n.name}` : "@voltro/plugin-ratelimit",
179
+ description: "Per-endpoint / per-subject / per-tenant rate limiting via the rpc interceptors. Configurable rules, three algorithms, static + dynamic per-tenant overrides.",
180
+ permissions: x,
181
+ declaredEnv: [
182
+ {
183
+ name: "CACHE_REDIS_URL",
184
+ required: !1,
185
+ secret: !0,
186
+ description: "Redis connection URL for the redis store (credential-carrying). Falls back to REDIS_URL."
187
+ },
188
+ {
189
+ name: "REDIS_URL",
190
+ required: !1,
191
+ secret: !0,
192
+ description: "Fallback Redis connection URL for the redis store (credential-carrying)."
193
+ },
194
+ {
195
+ name: "CACHE_REDIS_DRIVER",
196
+ required: !1,
197
+ secret: !1,
198
+ description: "Redis driver selector: \"http\" for the Upstash REST client, otherwise RESP."
199
+ },
200
+ {
201
+ name: "CACHE_REDIS_TOKEN",
202
+ required: !1,
203
+ secret: !0,
204
+ description: "Upstash REST token for the http redis driver. Falls back to UPSTASH_REDIS_REST_TOKEN."
205
+ },
206
+ {
207
+ name: "UPSTASH_REDIS_REST_TOKEN",
208
+ required: !1,
209
+ secret: !0,
210
+ description: "Fallback Upstash REST token for the http redis driver."
211
+ }
212
+ ],
213
+ ...b ? { onHttpRequest: b } : {},
214
+ errorSchemas: [{
215
+ schema: e,
216
+ import: {
217
+ module: "@voltro/plugin-ratelimit/errors",
218
+ name: "RateLimited"
219
+ }
220
+ }],
221
+ interceptMutation: _,
222
+ interceptQuery: _,
223
+ interceptAction: _,
224
+ bindDataStore: (e, t) => {
225
+ let n = t?.sql;
226
+ n && l.bindSql && l.bindSql(n).catch((e) => {
227
+ C = e;
228
+ });
229
+ },
230
+ onActivate: (e) => i.gen(function* () {
231
+ l.activate && (yield* i.promise(() => l.activate(e.env))), S = setInterval(() => l.sweep(Date.now()), 6e4), S.unref?.(), C !== void 0 && e.logger.warn("rate-limit: SQL store bind failed — limiter runs fail-open", { error: C instanceof Error ? C.message : String(C) }), e.logger.info("rate-limit active", {
232
+ rules: s.length,
233
+ hasDefault: n.default !== void 0,
234
+ store: typeof n.store == "string" ? n.store : n.store === void 0 ? "memory" : "custom"
235
+ });
236
+ }),
237
+ onDeactivate: () => i.gen(function* () {
238
+ S && clearInterval(S), l.deactivate && (yield* i.promise(() => l.deactivate()));
239
+ })
240
+ });
241
+ };
242
+ //#endregion
243
+ export { e as RateLimited, n as consumeBucket, o as memoryStore, t as parseWindow, b as rateLimitPlugin, r as redisStore };
@@ -0,0 +1,55 @@
1
+ import { Effect } from 'effect';
2
+ import { SqlClient } from '@effect/sql';
3
+
4
+ declare type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
5
+
6
+ export declare const postgresStore: (options?: PostgresStoreOptions) => RateLimitStore;
7
+
8
+ export declare interface PostgresStoreOptions {
9
+ /** Table name. Default `_voltro_ratelimit`. */
10
+ readonly table?: string;
11
+ /** Drop rows untouched for longer than this (ms) on sweep. Default 1h. */
12
+ readonly staleAfterMs?: number;
13
+ }
14
+
15
+ declare interface RateLimitDecision {
16
+ readonly allowed: boolean;
17
+ /** Remaining allowance in the current window (best-effort estimate). */
18
+ readonly remaining: number;
19
+ /** Epoch-ms when the bucket is expected to be fully replenished. */
20
+ readonly resetAtMs: number;
21
+ /** How long the caller should wait before retrying. 0 when allowed. */
22
+ readonly retryAfterMs: number;
23
+ }
24
+
25
+ declare interface RateLimitStore {
26
+ /** Atomically consume one unit for `key` against `limit` at `nowMs`. */
27
+ readonly consume: (key: string, limit: ResolvedLimit, nowMs: number) => Effect.Effect<RateLimitDecision>;
28
+ /** Drop entries untouched long enough to be irrelevant. Called on a timer. */
29
+ readonly sweep: (nowMs: number) => void;
30
+ /** Current number of tracked keys (diagnostics / tests). `-1` when the
31
+ * store can't answer synchronously (e.g. postgres). */
32
+ readonly size: () => number;
33
+ /** Optional one-shot setup at plugin activate (open pools, create
34
+ * tables). Receives the process env. */
35
+ readonly activate?: (env: NodeJS.ProcessEnv) => Promise<void>;
36
+ /** Optional teardown at plugin deactivate (close pools). */
37
+ readonly deactivate?: () => Promise<void>;
38
+ /** Optional bind of the framework's already-open `SqlClient` (from the
39
+ * plugin's `bindDataStore(store, ctx)` → `ctx.sql`). A SQL-backed store
40
+ * runs through THIS client instead of standing up its own pool; creates
41
+ * its bookkeeping table here. Not present on memory / redis stores. */
42
+ readonly bindSql?: (sql: SqlClient.SqlClient) => Promise<void>;
43
+ }
44
+
45
+ /** The fully-resolved limit the store consumes against. All dynamic /
46
+ * per-tenant resolution has already collapsed into these scalars. */
47
+ declare interface ResolvedLimit {
48
+ readonly limit: number;
49
+ readonly windowMs: number;
50
+ readonly algorithm: Algorithm;
51
+ /** Token-bucket capacity. Equals `limit` unless an explicit burst was set. */
52
+ readonly burst: number;
53
+ }
54
+
55
+ export { }