enerthya.dev-antiflood 1.0.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/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # enerthya.dev-antiflood
2
+
3
+ Rate limiter avançado para o bot Enerthya.
4
+
5
+ Keyed por `commandName + userId + guildId`, com **burst window deslizante**, **penalidade progressiva** (aditiva ou exponencial) e **whitelist de cargos**.
6
+
7
+ Depende de `enerthya.dev-common` para formatação de tempo — não reimplementa o que já existe.
8
+
9
+ ---
10
+
11
+ ## Instalação
12
+
13
+ ```bash
14
+ npm install enerthya.dev-antiflood
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Uso básico
20
+
21
+ ```js
22
+ const { AntifloodManager, isBlocked, formatRetryAfter } = require("enerthya.dev-antiflood");
23
+
24
+ const antiflood = new AntifloodManager({
25
+ globalRule: {
26
+ windowMs: 5000, // janela deslizante de 5s
27
+ maxHits: 3, // máximo de 3 usos nessa janela
28
+ penaltyMode: "ADDITIVE",
29
+ penaltyStep: 10000, // +10s de penalidade por hit excedente
30
+ maxPenalty: 60000, // teto de 60s de penalidade
31
+ },
32
+ whitelistRoleIds: ["ID_CARGO_ADMIN"],
33
+ });
34
+
35
+ // Dentro do handler de comando:
36
+ const result = antiflood.check({
37
+ userId: interaction.user.id,
38
+ guildId: interaction.guildId,
39
+ commandName: "ban",
40
+ memberRoleIds: interaction.member.roles.cache.map(r => r.id),
41
+ });
42
+
43
+ if (isBlocked(result)) {
44
+ return interaction.reply({
45
+ content: `Você está usando este comando rápido demais. Aguarde ${formatRetryAfter(result.retryAfterMs)}.`,
46
+ ephemeral: true,
47
+ });
48
+ }
49
+ ```
50
+
51
+ ---
52
+
53
+ ## API
54
+
55
+ ### `new AntifloodManager(options?)`
56
+
57
+ | Opção | Tipo | Padrão | Descrição |
58
+ |---|---|---|---|
59
+ | `globalRule` | `object` | veja DEFAULT_RULE | Regra aplicada quando não há regra por comando |
60
+ | `whitelistRoleIds` | `string[]` | `[]` | Cargos que sempre passam sem checagem |
61
+ | `enabled` | `boolean` | `true` | Master switch |
62
+
63
+ #### `.setRule(commandName, ruleConfig)` → `this`
64
+ Registra uma regra específica para um comando. Tem prioridade sobre a global.
65
+
66
+ #### `.addWhitelist(...roleIds)` → `this`
67
+ Adiciona cargos à whitelist em runtime.
68
+
69
+ #### `.removeWhitelist(roleId)` → `this`
70
+ Remove um cargo da whitelist.
71
+
72
+ #### `.check({ userId, guildId?, commandName?, memberRoleIds? })`
73
+ Retorna:
74
+ ```js
75
+ {
76
+ result: "ALLOWED" | "THROTTLED" | "PENALIZED" | "WHITELISTED",
77
+ retryAfterMs: number, // ms até poder tentar de novo (0 se liberado)
78
+ hitsInWindow: number, // hits acumulados na janela atual
79
+ penaltyUntil: number, // timestamp fim da penalidade (0 se não há)
80
+ rule: object, // regra que foi aplicada
81
+ }
82
+ ```
83
+
84
+ #### `.reset({ userId, guildId?, commandName? })`
85
+ Desbloqueia manualmente um usuário para um comando+guild.
86
+
87
+ #### `.resetAll()`
88
+ Limpa todo o estado (ex: restart do bot).
89
+
90
+ #### `.disable()` / `.enable()`
91
+ Liga/desliga o rate limiter globalmente.
92
+
93
+ ---
94
+
95
+ ### `createRule(config?)`
96
+ Valida e retorna um objeto de regra frozen. Útil para criar regras de forma isolada.
97
+
98
+ ```js
99
+ const { createRule, PENALTY_MODE } = require("enerthya.dev-antiflood");
100
+
101
+ const strictRule = createRule({
102
+ windowMs: 3000,
103
+ maxHits: 1,
104
+ penaltyMode: PENALTY_MODE.EXPONENTIAL,
105
+ penaltyStep: 5000,
106
+ maxPenalty: 120000,
107
+ });
108
+ ```
109
+
110
+ ---
111
+
112
+ ### `FLOOD_RESULT` (constantes)
113
+ | Valor | Descrição |
114
+ |---|---|
115
+ | `ALLOWED` | Passou normalmente |
116
+ | `THROTTLED` | Bloqueado até janela deslizar (sem penalidade progressiva) |
117
+ | `PENALIZED` | Bloqueado com penalidade progressiva ativa |
118
+ | `WHITELISTED` | Bypassed por cargo na whitelist |
119
+
120
+ ### `PENALTY_MODE` (constantes)
121
+ | Valor | Comportamento |
122
+ |---|---|
123
+ | `NONE` | Bloqueia até a janela deslizar — sem penalidade extra |
124
+ | `ADDITIVE` | Cada hit excedente adiciona `penaltyStep` ms à trava |
125
+ | `EXPONENTIAL` | Cada hit excedente dobra a duração da trava (`2^n * penaltyStep`) |
126
+
127
+ ---
128
+
129
+ ### `formatRetryAfter(ms)` → `string`
130
+ Converte ms em texto PT-BR legível. Delega para `enerthya.dev-common`.
131
+ ```
132
+ formatRetryAfter(1500) // "1 segundo"
133
+ formatRetryAfter(65000) // "1 minuto, 5 segundos"
134
+ ```
135
+
136
+ ### `isBlocked(checkResult)` → `boolean`
137
+ `true` se `result` é `THROTTLED` ou `PENALIZED`.
138
+
139
+ ---
140
+
141
+ ## Dependências
142
+
143
+ | Pacote | Uso |
144
+ |---|---|
145
+ | `enerthya.dev-common` | `formatTime` para formatar retryAfterMs em PT-BR |
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "enerthya.dev-antiflood",
3
+ "version": "1.0.0",
4
+ "description": "Advanced per-user+command+guild rate limiter with burst window, progressive penalty and role whitelist.",
5
+ "main": "src/index.js",
6
+ "license": "MIT",
7
+ "keywords": ["ratelimit", "antiflood", "discord", "bot", "cooldown"],
8
+ "engines": { "node": ">=16.0.0" },
9
+ "dependencies": {
10
+ "enerthya.dev-common": "1.0.0"
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ // Antiflood — constants
2
+
3
+ const FLOOD_RESULT = Object.freeze({
4
+ ALLOWED: "ALLOWED", // request passed
5
+ THROTTLED: "THROTTLED", // rate limit exceeded — caller should reject
6
+ PENALIZED: "PENALIZED", // rate limit exceeded + progressive penalty applied
7
+ WHITELISTED: "WHITELISTED", // bypassed due to whitelist
8
+ });
9
+
10
+ const PENALTY_MODE = Object.freeze({
11
+ NONE: "NONE", // no progressive penalty — just block until window expires
12
+ ADDITIVE: "ADDITIVE", // each excess hit adds penaltyStep ms to the lock
13
+ EXPONENTIAL: "EXPONENTIAL", // each excess hit doubles the lock duration
14
+ });
15
+
16
+ // Default rule applied when no custom config is provided
17
+ const DEFAULT_RULE = Object.freeze({
18
+ windowMs: 5000, // sliding window size in ms
19
+ maxHits: 3, // allowed hits per window
20
+ penaltyMode: PENALTY_MODE.ADDITIVE,
21
+ penaltyStep: 5000, // ms added per excess hit (ADDITIVE) or base for EXPONENTIAL
22
+ maxPenalty: 60000, // hard cap on penalty duration (ms)
23
+ });
24
+
25
+ module.exports = { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE };
@@ -0,0 +1,185 @@
1
+ // AntifloodManager — central rate limiter.
2
+ // Keyed by: commandName + userId + guildId (any field can be wildcarded with "*")
3
+ // Whitelisted roleIds bypass all checks.
4
+
5
+ const { BucketStore } = require("./BucketStore");
6
+ const { createRule } = require("./AntifloodRule");
7
+ const { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE } = require("../constants");
8
+
9
+ class AntifloodManager {
10
+ /**
11
+ * @param {object} [options]
12
+ * @param {object} [options.globalRule] — default rule applied when no command-level rule matches
13
+ * @param {Set<string>|string[]} [options.whitelistRoleIds] — roleIds that always bypass flood checks
14
+ * @param {boolean} [options.enabled] — master switch (default true)
15
+ */
16
+ constructor(options = {}) {
17
+ this._globalRule = createRule(options.globalRule || {});
18
+ this._commandRules = new Map(); // commandName → rule
19
+ this._store = new BucketStore();
20
+ this._whitelist = new Set(options.whitelistRoleIds || []);
21
+ this._enabled = options.enabled !== false;
22
+ }
23
+
24
+ // ── Configuration ─────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Register a per-command rule. Overrides the global rule for that command.
28
+ * @param {string} commandName
29
+ * @param {object} ruleConfig
30
+ */
31
+ setRule(commandName, ruleConfig) {
32
+ this._commandRules.set(commandName, createRule(ruleConfig));
33
+ return this;
34
+ }
35
+
36
+ /**
37
+ * Add one or more roleIds to the whitelist.
38
+ * @param {...string} roleIds
39
+ */
40
+ addWhitelist(...roleIds) {
41
+ for (const id of roleIds) this._whitelist.add(String(id));
42
+ return this;
43
+ }
44
+
45
+ /**
46
+ * Remove a roleId from the whitelist.
47
+ * @param {string} roleId
48
+ */
49
+ removeWhitelist(roleId) {
50
+ this._whitelist.delete(String(roleId));
51
+ return this;
52
+ }
53
+
54
+ /** Disable all flood checks (e.g. during maintenance). */
55
+ disable() { this._enabled = false; return this; }
56
+
57
+ /** Re-enable flood checks. */
58
+ enable() { this._enabled = true; return this; }
59
+
60
+ // ── Check ─────────────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Check if the given request should be allowed or throttled.
64
+ *
65
+ * @param {object} params
66
+ * @param {string} params.userId
67
+ * @param {string} [params.guildId] — optional; included in key for guild isolation
68
+ * @param {string} [params.commandName] — optional; used to look up per-command rule
69
+ * @param {string[]} [params.memberRoleIds] — user's current roles, checked against whitelist
70
+ *
71
+ * @returns {{
72
+ * result: string, // FLOOD_RESULT value
73
+ * retryAfterMs: number, // ms until the user can try again (0 if ALLOWED/WHITELISTED)
74
+ * hitsInWindow: number, // how many hits recorded in current window
75
+ * penaltyUntil: number, // timestamp when penalty expires (0 if none)
76
+ * rule: object // the rule that was applied
77
+ * }}
78
+ */
79
+ check({ userId, guildId = "*", commandName = "*", memberRoleIds = [] }) {
80
+ // Master switch
81
+ if (!this._enabled) {
82
+ return this._buildResult(FLOOD_RESULT.ALLOWED, 0, 0, 0, this._globalRule);
83
+ }
84
+
85
+ // Whitelist check — any matching roleId bypasses everything
86
+ for (const rid of memberRoleIds) {
87
+ if (this._whitelist.has(String(rid))) {
88
+ return this._buildResult(FLOOD_RESULT.WHITELISTED, 0, 0, 0, this._globalRule);
89
+ }
90
+ }
91
+
92
+ const rule = this._commandRules.get(commandName) || this._globalRule;
93
+ const key = this._buildKey(commandName, userId, guildId);
94
+ const now = Date.now();
95
+
96
+ // Try to reset expired penalty first
97
+ this._store.tryResetPenalty(key, now);
98
+
99
+ // Prune stale hits from the sliding window
100
+ this._store.prune(key, rule.windowMs, now);
101
+
102
+ const bucket = this._store.get(key);
103
+
104
+ // ── Active penalty lock ──────────────────────────────────────────────────
105
+ if (bucket.penaltyUntil > now) {
106
+ return this._buildResult(
107
+ FLOOD_RESULT.PENALIZED,
108
+ bucket.penaltyUntil - now,
109
+ bucket.hits.length,
110
+ bucket.penaltyUntil,
111
+ rule,
112
+ );
113
+ }
114
+
115
+ // ── Hit is within quota ──────────────────────────────────────────────────
116
+ if (bucket.hits.length < rule.maxHits) {
117
+ this._store.addHit(key, now);
118
+ return this._buildResult(FLOOD_RESULT.ALLOWED, 0, bucket.hits.length, 0, rule);
119
+ }
120
+
121
+ // ── Quota exceeded ───────────────────────────────────────────────────────
122
+ if (rule.penaltyMode === PENALTY_MODE.NONE) {
123
+ // No progressive penalty — just block until the window slides
124
+ const oldestHit = bucket.hits[0];
125
+ const retryAfterMs = rule.windowMs - (now - oldestHit);
126
+ return this._buildResult(
127
+ FLOOD_RESULT.THROTTLED,
128
+ Math.max(retryAfterMs, 0),
129
+ bucket.hits.length,
130
+ 0,
131
+ rule,
132
+ );
133
+ }
134
+
135
+ // Progressive penalty
136
+ this._store.addHit(key, now);
137
+ const penaltyUntil = this._store.applyPenalty(
138
+ key,
139
+ rule.penaltyMode,
140
+ rule.penaltyStep,
141
+ rule.maxPenalty,
142
+ now,
143
+ );
144
+
145
+ return this._buildResult(
146
+ FLOOD_RESULT.PENALIZED,
147
+ penaltyUntil - now,
148
+ bucket.hits.length,
149
+ penaltyUntil,
150
+ rule,
151
+ );
152
+ }
153
+
154
+ // ── Manual controls ───────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Manually reset a user's flood state for a given command + guild.
158
+ * Use this when a DEV/OWNER wants to unblock someone immediately.
159
+ */
160
+ reset({ userId, guildId = "*", commandName = "*" }) {
161
+ this._store.reset(this._buildKey(commandName, userId, guildId));
162
+ }
163
+
164
+ /** Reset all flood state (e.g. on bot restart). */
165
+ resetAll() {
166
+ this._store.clear();
167
+ }
168
+
169
+ /** @returns {number} number of active tracked buckets. */
170
+ get activeBuckets() {
171
+ return this._store.size;
172
+ }
173
+
174
+ // ── Internals ─────────────────────────────────────────────────────────────
175
+
176
+ _buildKey(commandName, userId, guildId) {
177
+ return `${commandName}|${userId}|${guildId}`;
178
+ }
179
+
180
+ _buildResult(result, retryAfterMs, hitsInWindow, penaltyUntil, rule) {
181
+ return { result, retryAfterMs: Math.ceil(retryAfterMs), hitsInWindow, penaltyUntil, rule };
182
+ }
183
+ }
184
+
185
+ module.exports = { AntifloodManager };
@@ -0,0 +1,39 @@
1
+ // AntifloodRule — merges user-supplied config with defaults and validates it.
2
+
3
+ const { DEFAULT_RULE, PENALTY_MODE } = require("../constants");
4
+
5
+ /**
6
+ * Build a validated rule object from user-supplied partial config.
7
+ * All fields are optional — defaults are applied for missing ones.
8
+ *
9
+ * @param {object} [config]
10
+ * @param {number} [config.windowMs]
11
+ * @param {number} [config.maxHits]
12
+ * @param {string} [config.penaltyMode]
13
+ * @param {number} [config.penaltyStep]
14
+ * @param {number} [config.maxPenalty]
15
+ * @returns {{ windowMs: number, maxHits: number, penaltyMode: string, penaltyStep: number, maxPenalty: number }}
16
+ */
17
+ function createRule(config = {}) {
18
+ const rule = Object.assign({}, DEFAULT_RULE, config);
19
+
20
+ if (typeof rule.windowMs !== "number" || rule.windowMs <= 0) {
21
+ throw new Error(`[antiflood] windowMs must be a positive number, got: ${rule.windowMs}`);
22
+ }
23
+ if (typeof rule.maxHits !== "number" || rule.maxHits < 1) {
24
+ throw new Error(`[antiflood] maxHits must be >= 1, got: ${rule.maxHits}`);
25
+ }
26
+ if (!Object.values(PENALTY_MODE).includes(rule.penaltyMode)) {
27
+ throw new Error(`[antiflood] penaltyMode must be one of ${Object.values(PENALTY_MODE).join(", ")}, got: ${rule.penaltyMode}`);
28
+ }
29
+ if (typeof rule.penaltyStep !== "number" || rule.penaltyStep < 0) {
30
+ throw new Error(`[antiflood] penaltyStep must be >= 0, got: ${rule.penaltyStep}`);
31
+ }
32
+ if (typeof rule.maxPenalty !== "number" || rule.maxPenalty < 0) {
33
+ throw new Error(`[antiflood] maxPenalty must be >= 0, got: ${rule.maxPenalty}`);
34
+ }
35
+
36
+ return Object.freeze(rule);
37
+ }
38
+
39
+ module.exports = { createRule };
@@ -0,0 +1,110 @@
1
+ // BucketStore — stores sliding-window hit timestamps + penalty state per key.
2
+ // Each bucket: { hits: number[], penaltyUntil: number, excessCount: number }
3
+
4
+ class BucketStore {
5
+ constructor() {
6
+ /** @type {Map<string, { hits: number[], penaltyUntil: number, excessCount: number }>} */
7
+ this._store = new Map();
8
+ }
9
+
10
+ /**
11
+ * Get or create a bucket for the given key.
12
+ * @param {string} key
13
+ */
14
+ get(key) {
15
+ if (!this._store.has(key)) {
16
+ this._store.set(key, { hits: [], penaltyUntil: 0, excessCount: 0 });
17
+ }
18
+ return this._store.get(key);
19
+ }
20
+
21
+ /**
22
+ * Prune hits outside the sliding window and remove stale buckets.
23
+ * Should be called before every check to keep memory bounded.
24
+ * @param {string} key
25
+ * @param {number} windowMs
26
+ * @param {number} now
27
+ */
28
+ prune(key, windowMs, now) {
29
+ const bucket = this._store.get(key);
30
+ if (!bucket) return;
31
+
32
+ bucket.hits = bucket.hits.filter(ts => now - ts < windowMs);
33
+
34
+ // Remove bucket entirely if it has no hits and no active penalty
35
+ if (bucket.hits.length === 0 && bucket.penaltyUntil <= now) {
36
+ this._store.delete(key);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Add a hit timestamp to the bucket.
42
+ * @param {string} key
43
+ * @param {number} now
44
+ */
45
+ addHit(key, now) {
46
+ const bucket = this.get(key);
47
+ bucket.hits.push(now);
48
+ }
49
+
50
+ /**
51
+ * Apply progressive penalty based on excess hits.
52
+ * @param {string} key
53
+ * @param {"ADDITIVE"|"EXPONENTIAL"} mode
54
+ * @param {number} step — ms per excess hit (ADDITIVE) or base ms (EXPONENTIAL)
55
+ * @param {number} max — hard cap in ms
56
+ * @param {number} now
57
+ * @returns {number} penaltyUntil timestamp
58
+ */
59
+ applyPenalty(key, mode, step, max, now) {
60
+ const bucket = this.get(key);
61
+ bucket.excessCount += 1;
62
+
63
+ let addedMs;
64
+ if (mode === "EXPONENTIAL") {
65
+ // 2^(excessCount-1) * step, capped at max
66
+ addedMs = Math.min(Math.pow(2, bucket.excessCount - 1) * step, max);
67
+ } else {
68
+ // ADDITIVE: excessCount * step, capped at max
69
+ addedMs = Math.min(bucket.excessCount * step, max);
70
+ }
71
+
72
+ // Penalty stacks from current time, not from previous penaltyUntil
73
+ bucket.penaltyUntil = now + addedMs;
74
+ return bucket.penaltyUntil;
75
+ }
76
+
77
+ /**
78
+ * Reset excess counter after the penalty expires.
79
+ * @param {string} key
80
+ * @param {number} now
81
+ */
82
+ tryResetPenalty(key, now) {
83
+ const bucket = this._store.get(key);
84
+ if (!bucket) return;
85
+ if (bucket.penaltyUntil > 0 && bucket.penaltyUntil <= now) {
86
+ bucket.excessCount = 0;
87
+ bucket.penaltyUntil = 0;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Fully reset a key (e.g. when a DEV clears a user's flood state).
93
+ * @param {string} key
94
+ */
95
+ reset(key) {
96
+ this._store.delete(key);
97
+ }
98
+
99
+ /** @returns {number} total tracked buckets */
100
+ get size() {
101
+ return this._store.size;
102
+ }
103
+
104
+ /** Clear all buckets (for tests / restart). */
105
+ clear() {
106
+ this._store.clear();
107
+ }
108
+ }
109
+
110
+ module.exports = { BucketStore };
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ // enerthya.dev-antiflood
2
+ // Advanced per-user+command+guild rate limiter with burst window,
3
+ // progressive penalty (additive or exponential) and role whitelist.
4
+ // Zero dependencies. Node.js only.
5
+
6
+ const { AntifloodManager } = require("./core/AntifloodManager");
7
+ const { BucketStore } = require("./core/BucketStore");
8
+ const { createRule } = require("./core/AntifloodRule");
9
+ const { FLOOD_RESULT, PENALTY_MODE, DEFAULT_RULE } = require("./constants");
10
+ const { formatRetryAfter, isBlocked } = require("./utils");
11
+
12
+ module.exports = {
13
+ // Main class
14
+ AntifloodManager,
15
+
16
+ // Building blocks (for advanced usage / testing)
17
+ BucketStore,
18
+ createRule,
19
+
20
+ // Constants
21
+ FLOOD_RESULT,
22
+ PENALTY_MODE,
23
+ DEFAULT_RULE,
24
+
25
+ // Utilities
26
+ formatRetryAfter,
27
+ isBlocked,
28
+ };
@@ -0,0 +1,28 @@
1
+ // Utility helpers for antiflood consumers.
2
+
3
+ // formatTime is already implemented (and well-tested) in enerthya.dev-common.
4
+ // We import from there instead of reimplementing — per rule P16a.
5
+ const { formatTime } = require("enerthya.dev-common");
6
+
7
+ /**
8
+ * Format retryAfterMs into a human-readable Portuguese string.
9
+ * Delegates to enerthya.dev-common's formatTime (seconds → PT-BR).
10
+ * e.g. 1500 → "1 segundo" | 65000 → "1 minuto, 5 segundos"
11
+ *
12
+ * @param {number} ms
13
+ * @returns {string}
14
+ */
15
+ function formatRetryAfter(ms) {
16
+ return formatTime(Math.ceil(ms / 1000));
17
+ }
18
+
19
+ /**
20
+ * Return true when the check result means the request should be blocked.
21
+ * @param {{ result: string }} checkResult
22
+ * @returns {boolean}
23
+ */
24
+ function isBlocked(checkResult) {
25
+ return checkResult.result === "THROTTLED" || checkResult.result === "PENALIZED";
26
+ }
27
+
28
+ module.exports = { formatRetryAfter, isBlocked };
package/test.js ADDED
@@ -0,0 +1,371 @@
1
+ // enerthya.dev-antiflood — test suite
2
+ // Run: node test.js
3
+ // Uses only Node.js built-in assert. Zero external test runners.
4
+
5
+ const assert = require("assert");
6
+
7
+ const {
8
+ AntifloodManager,
9
+ BucketStore,
10
+ createRule,
11
+ FLOOD_RESULT,
12
+ PENALTY_MODE,
13
+ DEFAULT_RULE,
14
+ formatRetryAfter,
15
+ isBlocked,
16
+ } = require("./src");
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+
21
+ function test(name, fn) {
22
+ try {
23
+ fn();
24
+ console.log(` ✓ ${name}`);
25
+ passed++;
26
+ } catch (err) {
27
+ console.error(` ✗ ${name}`);
28
+ console.error(` ${err.message}`);
29
+ failed++;
30
+ }
31
+ }
32
+
33
+ // ── Constants ──────────────────────────────────────────────────────────────
34
+
35
+ console.log("\n[FLOOD_RESULT]");
36
+ test("has ALLOWED", () => assert.strictEqual(FLOOD_RESULT.ALLOWED, "ALLOWED"));
37
+ test("has THROTTLED", () => assert.strictEqual(FLOOD_RESULT.THROTTLED, "THROTTLED"));
38
+ test("has PENALIZED", () => assert.strictEqual(FLOOD_RESULT.PENALIZED, "PENALIZED"));
39
+ test("has WHITELISTED", () => assert.strictEqual(FLOOD_RESULT.WHITELISTED, "WHITELISTED"));
40
+ test("is frozen", () => assert.ok(Object.isFrozen(FLOOD_RESULT)));
41
+
42
+ console.log("\n[PENALTY_MODE]");
43
+ test("has NONE", () => assert.strictEqual(PENALTY_MODE.NONE, "NONE"));
44
+ test("has ADDITIVE", () => assert.strictEqual(PENALTY_MODE.ADDITIVE, "ADDITIVE"));
45
+ test("has EXPONENTIAL", () => assert.strictEqual(PENALTY_MODE.EXPONENTIAL, "EXPONENTIAL"));
46
+
47
+ // ── createRule ─────────────────────────────────────────────────────────────
48
+
49
+ console.log("\n[createRule]");
50
+ test("returns defaults when called with no args", () => {
51
+ const r = createRule();
52
+ assert.strictEqual(r.windowMs, DEFAULT_RULE.windowMs);
53
+ assert.strictEqual(r.maxHits, DEFAULT_RULE.maxHits);
54
+ assert.strictEqual(r.penaltyMode, DEFAULT_RULE.penaltyMode);
55
+ });
56
+ test("overrides specific fields", () => {
57
+ const r = createRule({ maxHits: 10, windowMs: 2000 });
58
+ assert.strictEqual(r.maxHits, 10);
59
+ assert.strictEqual(r.windowMs, 2000);
60
+ assert.strictEqual(r.penaltyMode, DEFAULT_RULE.penaltyMode); // kept default
61
+ });
62
+ test("throws on invalid windowMs", () => {
63
+ assert.throws(() => createRule({ windowMs: -1 }), /windowMs/);
64
+ });
65
+ test("throws on invalid maxHits", () => {
66
+ assert.throws(() => createRule({ maxHits: 0 }), /maxHits/);
67
+ });
68
+ test("throws on unknown penaltyMode", () => {
69
+ assert.throws(() => createRule({ penaltyMode: "INVALID" }), /penaltyMode/);
70
+ });
71
+ test("frozen result", () => assert.ok(Object.isFrozen(createRule())));
72
+
73
+ // ── BucketStore ────────────────────────────────────────────────────────────
74
+
75
+ console.log("\n[BucketStore]");
76
+ test("creates empty bucket on first get", () => {
77
+ const s = new BucketStore();
78
+ const b = s.get("k");
79
+ assert.deepStrictEqual(b.hits, []);
80
+ assert.strictEqual(b.penaltyUntil, 0);
81
+ assert.strictEqual(b.excessCount, 0);
82
+ });
83
+ test("addHit pushes timestamp", () => {
84
+ const s = new BucketStore();
85
+ s.addHit("k", 1000);
86
+ s.addHit("k", 2000);
87
+ assert.strictEqual(s.get("k").hits.length, 2);
88
+ });
89
+ test("prune removes hits outside window", () => {
90
+ const s = new BucketStore();
91
+ s.addHit("k", 1000);
92
+ s.addHit("k", 2000);
93
+ s.prune("k", 500, 2100); // window=500ms, now=2100 → only hit at 2000 survives
94
+ assert.strictEqual(s.get("k").hits.length, 1);
95
+ });
96
+ test("prune deletes stale bucket entirely", () => {
97
+ const s = new BucketStore();
98
+ s.addHit("k", 1000);
99
+ s.prune("k", 100, 5000); // all hits expired, no penalty
100
+ assert.strictEqual(s.size, 0);
101
+ });
102
+ test("applyPenalty ADDITIVE increases by step * excessCount", () => {
103
+ const s = new BucketStore();
104
+ const now = Date.now();
105
+ s.applyPenalty("k", "ADDITIVE", 5000, 60000, now);
106
+ assert.ok(s.get("k").penaltyUntil > now);
107
+ assert.strictEqual(s.get("k").excessCount, 1);
108
+ });
109
+ test("applyPenalty EXPONENTIAL doubles each call", () => {
110
+ const s = new BucketStore();
111
+ const now = 10000;
112
+ s.applyPenalty("k", "EXPONENTIAL", 1000, 60000, now);
113
+ const p1 = s.get("k").penaltyUntil;
114
+ s.applyPenalty("k", "EXPONENTIAL", 1000, 60000, now);
115
+ const p2 = s.get("k").penaltyUntil;
116
+ // second call: 2^1 * 1000 = 2000ms added vs first 2^0 * 1000 = 1000ms
117
+ assert.ok(p2 - now > p1 - now);
118
+ });
119
+ test("applyPenalty respects maxPenalty cap", () => {
120
+ const s = new BucketStore();
121
+ const now = 0;
122
+ // 100 excess hits with step=100000, max=5000 — should be capped
123
+ for (let i = 0; i < 100; i++) s.applyPenalty("k", "ADDITIVE", 100000, 5000, now);
124
+ assert.ok(s.get("k").penaltyUntil <= now + 5000);
125
+ });
126
+ test("reset deletes key", () => {
127
+ const s = new BucketStore();
128
+ s.addHit("k", 1000);
129
+ s.reset("k");
130
+ assert.strictEqual(s.size, 0);
131
+ });
132
+ test("clear empties all buckets", () => {
133
+ const s = new BucketStore();
134
+ s.addHit("a", 1000);
135
+ s.addHit("b", 2000);
136
+ s.clear();
137
+ assert.strictEqual(s.size, 0);
138
+ });
139
+ test("tryResetPenalty clears excessCount when penalty expired", () => {
140
+ const s = new BucketStore();
141
+ const bucket = s.get("k");
142
+ bucket.penaltyUntil = 1000;
143
+ bucket.excessCount = 5;
144
+ s.tryResetPenalty("k", 2000); // now > penaltyUntil
145
+ assert.strictEqual(s.get("k").excessCount, 0);
146
+ });
147
+ test("tryResetPenalty does not clear if penalty still active", () => {
148
+ const s = new BucketStore();
149
+ const bucket = s.get("k");
150
+ bucket.penaltyUntil = 9999999999;
151
+ bucket.excessCount = 3;
152
+ s.tryResetPenalty("k", 1000);
153
+ assert.strictEqual(s.get("k").excessCount, 3);
154
+ });
155
+
156
+ // ── AntifloodManager — basic flow ──────────────────────────────────────────
157
+
158
+ console.log("\n[AntifloodManager — basic flow]");
159
+ test("allows hits within maxHits", () => {
160
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3 } });
161
+ for (let i = 0; i < 3; i++) {
162
+ const r = af.check({ userId: "u1", commandName: "ping" });
163
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED, `hit ${i + 1} should be ALLOWED`);
164
+ }
165
+ });
166
+ test("throttles on 4th hit with NONE penalty", () => {
167
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3, penaltyMode: "NONE" } });
168
+ for (let i = 0; i < 3; i++) af.check({ userId: "u1" });
169
+ const r = af.check({ userId: "u1" });
170
+ assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
171
+ assert.ok(r.retryAfterMs > 0);
172
+ });
173
+ test("penalizes on 4th hit with ADDITIVE penalty", () => {
174
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 3, penaltyMode: "ADDITIVE", penaltyStep: 5000 } });
175
+ for (let i = 0; i < 3; i++) af.check({ userId: "u1" });
176
+ const r = af.check({ userId: "u1" });
177
+ assert.strictEqual(r.result, FLOOD_RESULT.PENALIZED);
178
+ assert.ok(r.penaltyUntil > Date.now());
179
+ });
180
+ test("penalized user stays blocked on next check", () => {
181
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2, penaltyMode: "ADDITIVE", penaltyStep: 60000 } });
182
+ for (let i = 0; i < 2; i++) af.check({ userId: "u1" });
183
+ af.check({ userId: "u1" }); // triggers penalty
184
+ const r = af.check({ userId: "u1" });
185
+ assert.strictEqual(r.result, FLOOD_RESULT.PENALIZED);
186
+ });
187
+ test("independent users don't affect each other", () => {
188
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
189
+ af.check({ userId: "u1" });
190
+ af.check({ userId: "u1" });
191
+ af.check({ userId: "u1" }); // u1 gets throttled/penalized
192
+ const r = af.check({ userId: "u2" }); // u2 unaffected
193
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
194
+ });
195
+ test("different guilds are isolated", () => {
196
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
197
+ af.check({ userId: "u1", guildId: "g1" });
198
+ af.check({ userId: "u1", guildId: "g1" });
199
+ af.check({ userId: "u1", guildId: "g1" }); // g1 throttled
200
+ const r = af.check({ userId: "u1", guildId: "g2" });
201
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
202
+ });
203
+ test("different commands are isolated", () => {
204
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
205
+ af.check({ userId: "u1", commandName: "ban" });
206
+ af.check({ userId: "u1", commandName: "ban" });
207
+ af.check({ userId: "u1", commandName: "ban" }); // ban throttled
208
+ const r = af.check({ userId: "u1", commandName: "ping" });
209
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
210
+ });
211
+ test("returns correct hitsInWindow count", () => {
212
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 5 } });
213
+ af.check({ userId: "u1" });
214
+ af.check({ userId: "u1" });
215
+ const r = af.check({ userId: "u1" });
216
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
217
+ assert.ok(r.hitsInWindow >= 2);
218
+ });
219
+
220
+ // ── AntifloodManager — per-command rules ──────────────────────────────────
221
+
222
+ console.log("\n[AntifloodManager — per-command rules]");
223
+ test("per-command rule overrides global rule", () => {
224
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 10 } });
225
+ af.setRule("ban", { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" });
226
+ af.check({ userId: "u1", commandName: "ban" });
227
+ const r = af.check({ userId: "u1", commandName: "ban" });
228
+ assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED); // stricter rule applied
229
+ });
230
+ test("global rule unaffected by per-command rule", () => {
231
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 10 } });
232
+ af.setRule("ban", { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" });
233
+ for (let i = 0; i < 9; i++) af.check({ userId: "u1", commandName: "ping" });
234
+ const r = af.check({ userId: "u1", commandName: "ping" }); // 10th hit — within maxHits: 10
235
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
236
+ });
237
+ test("setRule returns manager (chainable)", () => {
238
+ const af = new AntifloodManager();
239
+ const ret = af.setRule("cmd", { maxHits: 5 });
240
+ assert.strictEqual(ret, af);
241
+ });
242
+
243
+ // ── AntifloodManager — whitelist ───────────────────────────────────────────
244
+
245
+ console.log("\n[AntifloodManager — whitelist]");
246
+ test("whitelisted role bypasses flood check", () => {
247
+ const af = new AntifloodManager({
248
+ globalRule: { windowMs: 10000, maxHits: 1 },
249
+ whitelistRoleIds: ["role_admin"],
250
+ });
251
+ for (let i = 0; i < 20; i++) {
252
+ const r = af.check({ userId: "u1", memberRoleIds: ["role_admin"] });
253
+ assert.strictEqual(r.result, FLOOD_RESULT.WHITELISTED);
254
+ }
255
+ });
256
+ test("non-whitelisted role is not bypassed", () => {
257
+ const af = new AntifloodManager({
258
+ globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" },
259
+ whitelistRoleIds: ["role_admin"],
260
+ });
261
+ af.check({ userId: "u1", memberRoleIds: ["role_member"] });
262
+ const r = af.check({ userId: "u1", memberRoleIds: ["role_member"] });
263
+ assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
264
+ });
265
+ test("addWhitelist adds role at runtime", () => {
266
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 } });
267
+ af.addWhitelist("role_mod");
268
+ const r = af.check({ userId: "u1", memberRoleIds: ["role_mod"] });
269
+ assert.strictEqual(r.result, FLOOD_RESULT.WHITELISTED);
270
+ });
271
+ test("removeWhitelist removes role", () => {
272
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 }, whitelistRoleIds: ["role_x"] });
273
+ af.removeWhitelist("role_x");
274
+ af.check({ userId: "u1", memberRoleIds: ["role_x"] });
275
+ const r = af.check({ userId: "u1", memberRoleIds: ["role_x"] });
276
+ assert.notStrictEqual(r.result, FLOOD_RESULT.WHITELISTED);
277
+ });
278
+ test("addWhitelist is chainable", () => {
279
+ const af = new AntifloodManager();
280
+ const ret = af.addWhitelist("r1");
281
+ assert.strictEqual(ret, af);
282
+ });
283
+
284
+ // ── AntifloodManager — master switch ──────────────────────────────────────
285
+
286
+ console.log("\n[AntifloodManager — master switch]");
287
+ test("disabled manager always returns ALLOWED", () => {
288
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1 } });
289
+ af.disable();
290
+ for (let i = 0; i < 10; i++) {
291
+ const r = af.check({ userId: "u1" });
292
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
293
+ }
294
+ });
295
+ test("re-enabling resumes checks", () => {
296
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" } });
297
+ af.disable();
298
+ af.check({ userId: "u1" }); // should not count
299
+ af.check({ userId: "u1" });
300
+ af.enable();
301
+ af.check({ userId: "u1" }); // hit 1
302
+ const r = af.check({ userId: "u1" }); // hit 2 → throttled
303
+ assert.strictEqual(r.result, FLOOD_RESULT.THROTTLED);
304
+ });
305
+
306
+ // ── AntifloodManager — manual reset ───────────────────────────────────────
307
+
308
+ console.log("\n[AntifloodManager — manual reset]");
309
+ test("reset unblocks a throttled user", () => {
310
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 1, penaltyMode: "NONE" } });
311
+ af.check({ userId: "u1", commandName: "ban" });
312
+ af.check({ userId: "u1", commandName: "ban" }); // throttled
313
+ af.reset({ userId: "u1", commandName: "ban" });
314
+ const r = af.check({ userId: "u1", commandName: "ban" });
315
+ assert.strictEqual(r.result, FLOOD_RESULT.ALLOWED);
316
+ });
317
+ test("resetAll clears all state", () => {
318
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 2 } });
319
+ af.check({ userId: "u1" });
320
+ af.check({ userId: "u2" });
321
+ af.resetAll();
322
+ assert.strictEqual(af.activeBuckets, 0);
323
+ });
324
+ test("activeBuckets reflects current state", () => {
325
+ const af = new AntifloodManager({ globalRule: { windowMs: 10000, maxHits: 5 } });
326
+ assert.strictEqual(af.activeBuckets, 0);
327
+ af.check({ userId: "u1" });
328
+ af.check({ userId: "u2" });
329
+ assert.ok(af.activeBuckets >= 2);
330
+ });
331
+
332
+ // ── EXPONENTIAL penalty ────────────────────────────────────────────────────
333
+
334
+ console.log("\n[AntifloodManager — EXPONENTIAL penalty]");
335
+ test("exponential penalty grows on repeated violations", () => {
336
+ const af = new AntifloodManager({
337
+ globalRule: { windowMs: 60000, maxHits: 2, penaltyMode: "EXPONENTIAL", penaltyStep: 1000, maxPenalty: 999999 },
338
+ });
339
+ af.check({ userId: "u1" });
340
+ af.check({ userId: "u1" });
341
+ const r1 = af.check({ userId: "u1" }); // 1st excess → penaltyUntil = now + 2^0*1000
342
+ const r2 = af.check({ userId: "u1" }); // 2nd excess → penaltyUntil = now + 2^1*1000
343
+ assert.strictEqual(r1.result, FLOOD_RESULT.PENALIZED);
344
+ assert.strictEqual(r2.result, FLOOD_RESULT.PENALIZED);
345
+ assert.ok(r2.penaltyUntil >= r1.penaltyUntil);
346
+ });
347
+
348
+ // ── Utilities ─────────────────────────────────────────────────────────────
349
+
350
+ console.log("\n[formatRetryAfter — delegates to enerthya.dev-common]");
351
+ test("formats 1000ms as 1 segundo", () => assert.ok(formatRetryAfter(1000).includes("segundo")));
352
+ test("formats 60000ms includes minuto",() => assert.ok(formatRetryAfter(60000).includes("minuto")));
353
+ test("formats 0ms as 0 segundos", () => assert.strictEqual(formatRetryAfter(0), "0 segundos"));
354
+ test("rounds up fractional seconds", () => assert.ok(formatRetryAfter(1500).includes("segundo")));
355
+
356
+ console.log("\n[isBlocked]");
357
+ test("THROTTLED → true", () => assert.strictEqual(isBlocked({ result: "THROTTLED" }), true));
358
+ test("PENALIZED → true", () => assert.strictEqual(isBlocked({ result: "PENALIZED" }), true));
359
+ test("ALLOWED → false", () => assert.strictEqual(isBlocked({ result: "ALLOWED" }), false));
360
+ test("WHITELISTED → false",() => assert.strictEqual(isBlocked({ result: "WHITELISTED" }), false));
361
+
362
+ // ── Summary ────────────────────────────────────────────────────────────────
363
+
364
+ console.log(`\n${"─".repeat(45)}`);
365
+ console.log(` ${passed + failed} tests: ${passed} passed, ${failed} failed`);
366
+ if (failed > 0) {
367
+ console.error(" ❌ SOME TESTS FAILED");
368
+ process.exit(1);
369
+ } else {
370
+ console.log(" ✅ ALL TESTS PASSED");
371
+ }