@roboteby/parry 1.1.0-rc.1

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +284 -0
  4. package/config/defaults.js +65 -0
  5. package/constants/patterns.js +77 -0
  6. package/package.json +89 -0
  7. package/src/admin/admin-router.js +106 -0
  8. package/src/admin/auth/admin-auth.js +176 -0
  9. package/src/admin/auth/index.js +13 -0
  10. package/src/admin/auth/strategies/alb-auth.js +49 -0
  11. package/src/admin/auth/strategies/cloudflare-access.js +34 -0
  12. package/src/admin/auth/strategies/combined.js +50 -0
  13. package/src/admin/auth/strategies/ip-allowlist.js +13 -0
  14. package/src/admin/auth/strategies/none.js +20 -0
  15. package/src/admin/auth/strategies/token.js +25 -0
  16. package/src/admin/auth/strategies/trusted-proxy.js +52 -0
  17. package/src/admin/auth/utils/constant-time.js +18 -0
  18. package/src/admin/auth/utils/external-identity.js +156 -0
  19. package/src/admin/auth/utils/header-utils.js +39 -0
  20. package/src/admin/auth/utils/result.js +39 -0
  21. package/src/admin/ban-normalizer.js +98 -0
  22. package/src/admin/index.js +12 -0
  23. package/src/admin/response.js +41 -0
  24. package/src/brute-force/brute-force-guard.js +268 -0
  25. package/src/brute-force/index.js +32 -0
  26. package/src/brute-force/key-builder.js +164 -0
  27. package/src/brute-force/result.js +35 -0
  28. package/src/core/engine.js +264 -0
  29. package/src/core/index.js +7 -0
  30. package/src/core/logger.js +3 -0
  31. package/src/core/rate-limit-result.js +13 -0
  32. package/src/core/rateLimiter.js +3 -0
  33. package/src/core/scoring.js +18 -0
  34. package/src/core/threat-event.js +69 -0
  35. package/src/detectors/hpp.js +30 -0
  36. package/src/detectors/index.js +19 -0
  37. package/src/detectors/nosql.js +53 -0
  38. package/src/detectors/path-traversal.js +72 -0
  39. package/src/detectors/prototype-pollution.js +69 -0
  40. package/src/detectors/request-shape.js +76 -0
  41. package/src/detectors/sql.js +18 -0
  42. package/src/detectors/xss.js +18 -0
  43. package/src/events/event-bus.js +51 -0
  44. package/src/events/index.js +19 -0
  45. package/src/events/memory-event-store.js +64 -0
  46. package/src/events/sanitize-event.js +54 -0
  47. package/src/events/threat-event.js +174 -0
  48. package/src/express/ip-resolver.js +109 -0
  49. package/src/express/middleware.js +379 -0
  50. package/src/express/request-targets.js +35 -0
  51. package/src/express/response.js +14 -0
  52. package/src/index.js +41 -0
  53. package/src/logger/console-reporter.js +75 -0
  54. package/src/middleware/index.js +7 -0
  55. package/src/middleware/parry_ddos.js +3 -0
  56. package/src/observability/index.js +6 -0
  57. package/src/observability/metrics.js +61 -0
  58. package/src/observability/snapshot.js +48 -0
  59. package/src/policies/index.js +15 -0
  60. package/src/policies/matcher.js +48 -0
  61. package/src/policies/normalize-policy.js +94 -0
  62. package/src/policies/presets.js +34 -0
  63. package/src/rate-limit/keys.js +7 -0
  64. package/src/rate-limit/limiter.js +124 -0
  65. package/src/stores/README.md +51 -0
  66. package/src/stores/index.js +6 -0
  67. package/src/stores/memory-store.js +278 -0
  68. package/src/stores/redis-store.js +349 -0
  69. package/src/utils/decode.js +56 -0
  70. package/src/utils/flatten.js +27 -0
  71. package/src/utils/normalize.js +21 -0
  72. package/types/index.d.ts +555 -0
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ function matchesPolicy(policy, requestData) {
4
+ if (!policy || !policy.match) return false;
5
+
6
+ return (
7
+ matchesMethod(policy.match.method, requestData.method) &&
8
+ matchesPath(policy.match.path, requestData.path || requestData.url || '/')
9
+ );
10
+ }
11
+
12
+ function findMatchingPolicy(policies, requestData) {
13
+ return (policies || []).find((policy) => matchesPolicy(policy, requestData)) || null;
14
+ }
15
+
16
+ function matchesMethod(expected, method) {
17
+ if (!expected) return true;
18
+
19
+ const actual = String(method || '').toUpperCase();
20
+ const methods = Array.isArray(expected) ? expected : [expected];
21
+ return methods.some((item) => String(item || '').toUpperCase() === actual);
22
+ }
23
+
24
+ function matchesPath(expected, path) {
25
+ if (!expected) return true;
26
+
27
+ const actual = stripQuery(path || '/');
28
+ const paths = Array.isArray(expected) ? expected : [expected];
29
+
30
+ return paths.some((item) => {
31
+ if (item instanceof RegExp) return item.test(actual);
32
+
33
+ const pattern = String(item || '');
34
+ if (pattern.endsWith('*')) {
35
+ return actual.startsWith(pattern.slice(0, -1));
36
+ }
37
+
38
+ return actual === pattern;
39
+ });
40
+ }
41
+
42
+ function stripQuery(value) {
43
+ const path = String(value || '/');
44
+ const index = path.indexOf('?');
45
+ return index === -1 ? path : path.slice(0, index);
46
+ }
47
+
48
+ module.exports = { findMatchingPolicy, matchesPolicy, matchesMethod, matchesPath, stripQuery };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const { getPresetPolicies } = require('./presets');
4
+
5
+ const BRUTE_FORCE_DEFAULTS = {
6
+ enabled: false,
7
+ maxAttempts: 5,
8
+ windowMs: 15 * 60_000,
9
+ blockDurationMs: 10 * 60_000,
10
+ keys: ['ip'],
11
+ failureStatusCodes: [400, 401, 403],
12
+ successStatusCodes: [200, 201, 204],
13
+ blockedStatusCode: 429,
14
+ resetOnSuccess: true,
15
+ };
16
+
17
+ function buildPolicies(options = {}) {
18
+ const presetName = options.preset || 'off';
19
+ const presetPolicies = options.bruteForce === false ? [] : getPresetPolicies(presetName);
20
+ const explicitPolicies = Array.isArray(options.policies) ? options.policies : [];
21
+ const merged = mergePolicies(presetPolicies, explicitPolicies);
22
+
23
+ return merged.map((policy) => normalizePolicy(policy, options));
24
+ }
25
+
26
+ function normalizePolicy(policy, options = {}) {
27
+ if (!policy || !policy.name) {
28
+ throw new Error('Parry policy requires a name.');
29
+ }
30
+
31
+ const bruteForceDisabled =
32
+ options.bruteForce === false ||
33
+ (options.bruteForce &&
34
+ typeof options.bruteForce === 'object' &&
35
+ options.bruteForce.enabled === false);
36
+
37
+ return {
38
+ name: String(policy.name),
39
+ match: policy.match || {},
40
+ inheritGlobalRateLimit: policy.inheritGlobalRateLimit !== false,
41
+ rateLimit: normalizeRateLimit(policy.rateLimit),
42
+ bruteForce: normalizeBruteForce(policy.bruteForce, bruteForceDisabled),
43
+ };
44
+ }
45
+
46
+ function normalizeRateLimit(rateLimit) {
47
+ if (!rateLimit || rateLimit.enabled === false) return { enabled: false };
48
+
49
+ return {
50
+ enabled: true,
51
+ max: rateLimit.max || rateLimit.maxRequests || 10,
52
+ windowMs: rateLimit.windowMs || 60_000,
53
+ key: rateLimit.key || 'ip',
54
+ };
55
+ }
56
+
57
+ function normalizeBruteForce(bruteForce, forceDisabled) {
58
+ if (forceDisabled || !bruteForce || bruteForce.enabled === false) {
59
+ return { ...BRUTE_FORCE_DEFAULTS, enabled: false };
60
+ }
61
+
62
+ return {
63
+ ...BRUTE_FORCE_DEFAULTS,
64
+ ...bruteForce,
65
+ enabled: true,
66
+ keys:
67
+ Array.isArray(bruteForce.keys) && bruteForce.keys.length > 0
68
+ ? bruteForce.keys
69
+ : BRUTE_FORCE_DEFAULTS.keys,
70
+ failureStatusCodes: normalizeStatusList(
71
+ bruteForce.failureStatusCodes,
72
+ BRUTE_FORCE_DEFAULTS.failureStatusCodes
73
+ ),
74
+ successStatusCodes: normalizeStatusList(
75
+ bruteForce.successStatusCodes,
76
+ BRUTE_FORCE_DEFAULTS.successStatusCodes
77
+ ),
78
+ resetOnSuccess: bruteForce.resetOnSuccess !== false,
79
+ };
80
+ }
81
+
82
+ function normalizeStatusList(value, fallback) {
83
+ if (!Array.isArray(value) || value.length === 0) return fallback;
84
+ return value.map((status) => Number(status)).filter((status) => Number.isInteger(status));
85
+ }
86
+
87
+ function mergePolicies(presetPolicies, explicitPolicies) {
88
+ const byName = new Map();
89
+ for (const policy of presetPolicies) byName.set(policy.name, policy);
90
+ for (const policy of explicitPolicies) byName.set(policy.name, policy);
91
+ return [...byName.values()];
92
+ }
93
+
94
+ module.exports = { buildPolicies, normalizePolicy, BRUTE_FORCE_DEFAULTS };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const COMMON_AUTH_PATHS = ['/login', '/signin', '/auth/login', '/api/login', '/api/auth/login'];
4
+
5
+ function getPresetPolicies(name) {
6
+ if (!name || name === 'off') return [];
7
+ if (name === 'recommended') return createAuthPolicies('recommended', 10, 10 * 60_000, 20);
8
+ if (name === 'strict') return createAuthPolicies('strict', 5, 15 * 60_000, 10);
9
+
10
+ throw new Error(`Unknown Parry preset: ${name}`);
11
+ }
12
+
13
+ function createAuthPolicies(prefix, maxAttempts, blockDurationMs, routeMax) {
14
+ return COMMON_AUTH_PATHS.map((path) => ({
15
+ name: `${prefix}-auth-${path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '')}`,
16
+ match: { method: 'POST', path },
17
+ rateLimit: {
18
+ enabled: true,
19
+ max: routeMax,
20
+ windowMs: 60_000,
21
+ key: 'ip',
22
+ },
23
+ bruteForce: {
24
+ enabled: true,
25
+ maxAttempts,
26
+ windowMs: 15 * 60_000,
27
+ blockDurationMs,
28
+ keys: ['ip', 'body.email', 'ip+body.email', 'body.username', 'ip+body.username'],
29
+ resetOnSuccess: true,
30
+ },
31
+ }));
32
+ }
33
+
34
+ module.exports = { getPresetPolicies };
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ function normalizeRateLimitKey(value) {
4
+ return String(value || 'unknown');
5
+ }
6
+
7
+ module.exports = { normalizeRateLimitKey };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const { MemoryStore } = require('../stores/memory-store');
4
+ const { createRateLimitResult } = require('../core/rate-limit-result');
5
+ const { normalizeRateLimitKey } = require('./keys');
6
+
7
+ class RateLimiter {
8
+ /**
9
+ * @param {{
10
+ * rateLimit?: boolean | { enabled?: boolean, max?: number, maxRequests?: number, windowMs?: number, headers?: boolean },
11
+ * maxRequests?: number,
12
+ * windowMs?: number,
13
+ * suspiciousThreshold?: number,
14
+ * banDurationMs?: number,
15
+ * store?: import('../stores/memory-store').MemoryStore
16
+ * }} config
17
+ * @param {object} [store]
18
+ */
19
+ constructor(config, store) {
20
+ const rateLimit = normalizeRateLimitOptions(config);
21
+
22
+ this.enabled = rateLimit.enabled;
23
+ this.maxRequests = rateLimit.maxRequests;
24
+ this.windowMs = rateLimit.windowMs;
25
+ this.headers = rateLimit.headers;
26
+ this.suspiciousThreshold = config.suspiciousThreshold;
27
+ this.banDurationMs = config.banDurationMs;
28
+ this.suspiciousTtlMs = Math.max(this.windowMs, this.banDurationMs, 600_000);
29
+ this.store = store || config.store || new MemoryStore();
30
+
31
+ this._cleanupInterval = setInterval(() => this._cleanup(), 600_000);
32
+ if (this._cleanupInterval.unref) this._cleanupInterval.unref();
33
+ }
34
+
35
+ async check(ip) {
36
+ const key = normalizeRateLimitKey(ip);
37
+ const ban = await this.store.isBanned(key);
38
+
39
+ if (ban.banned) {
40
+ return createRateLimitResult({
41
+ limited: false,
42
+ banned: true,
43
+ remaining: 0,
44
+ resetAt: ban.banExpiresAt,
45
+ banExpiresAt: ban.banExpiresAt,
46
+ });
47
+ }
48
+
49
+ const counter = await this.store.incrementRateLimit(key, this.windowMs);
50
+ const remaining = Math.max(0, this.maxRequests - counter.count);
51
+
52
+ if (counter.count > this.maxRequests) {
53
+ return createRateLimitResult({
54
+ limited: true,
55
+ banned: false,
56
+ remaining: 0,
57
+ resetAt: counter.resetAt,
58
+ banExpiresAt: null,
59
+ });
60
+ }
61
+
62
+ return createRateLimitResult({
63
+ limited: false,
64
+ banned: false,
65
+ remaining,
66
+ resetAt: counter.resetAt,
67
+ banExpiresAt: null,
68
+ });
69
+ }
70
+
71
+ async recordSuspicious(ip) {
72
+ const key = normalizeRateLimitKey(ip);
73
+ const suspicious = await this.store.recordSuspicious(key, this.suspiciousTtlMs, {
74
+ reason: 'Threat detected',
75
+ });
76
+
77
+ if (suspicious.count >= this.suspiciousThreshold) {
78
+ return this.store.ban(key, this.banDurationMs, {
79
+ reason: 'Suspicious activity threshold reached',
80
+ suspiciousCount: suspicious.count,
81
+ });
82
+ }
83
+
84
+ return suspicious;
85
+ }
86
+
87
+ async unban(ip) {
88
+ return this.store.unban(normalizeRateLimitKey(ip));
89
+ }
90
+
91
+ async snapshot() {
92
+ if (typeof this.store.snapshot === 'function') {
93
+ return this.store.snapshot(this.windowMs);
94
+ }
95
+
96
+ return [];
97
+ }
98
+
99
+ async _cleanup() {
100
+ if (typeof this.store.cleanup === 'function') {
101
+ await this.store.cleanup();
102
+ }
103
+ }
104
+
105
+ destroy() {
106
+ clearInterval(this._cleanupInterval);
107
+ if (typeof this.store.close === 'function') return this.store.close();
108
+ if (typeof this.store.clear === 'function') return this.store.clear();
109
+ }
110
+ }
111
+
112
+ function normalizeRateLimitOptions(config) {
113
+ const option = config.rateLimit;
114
+ const objectConfig = option && typeof option === 'object' ? option : {};
115
+
116
+ return {
117
+ enabled: option !== false && objectConfig.enabled !== false,
118
+ maxRequests: objectConfig.max || objectConfig.maxRequests || config.maxRequests || 100,
119
+ windowMs: objectConfig.windowMs || config.windowMs || 60_000,
120
+ headers: objectConfig.headers !== false,
121
+ };
122
+ }
123
+
124
+ module.exports = { RateLimiter, normalizeRateLimitOptions };
@@ -0,0 +1,51 @@
1
+ # Store Contract
2
+
3
+ Stores keep rate limit, temporary ban, and suspicious activity state for Parry.
4
+ Methods may return values directly or return Promises.
5
+
6
+ ```js
7
+ store.incrementRateLimit(key, windowMs);
8
+ // -> { key, count, resetAt, ttlMs }
9
+
10
+ store.getRateLimit(key);
11
+ // -> { key, count, resetAt, ttlMs }
12
+
13
+ store.resetRateLimit(key);
14
+ store.ban(key, ttlMs, metadata);
15
+ store.isBanned(key);
16
+ // -> { key, banned, banExpiresAt, metadata }
17
+
18
+ store.unban(key);
19
+ store.recordSuspicious(key, ttlMs, metadata);
20
+ // -> { key, count, resetAt, ttlMs }
21
+
22
+ store.incrementCounter(key, ttlMs, metadata);
23
+ // -> { key, count, resetAt, ttlMs }
24
+
25
+ store.getCounter(key);
26
+ // -> { key, count, resetAt, ttlMs }
27
+
28
+ store.resetCounter(key);
29
+ store.blockKey(key, ttlMs, metadata);
30
+ store.isBlocked(key);
31
+ // -> { key, blocked, blockExpiresAt, metadata }
32
+
33
+ store.unblockKey(key);
34
+
35
+ store.listBans?.({ limit, offset });
36
+ // -> [{ key, createdAt, banExpiresAt, ttlMs, metadata }]
37
+
38
+ store.listBlocks?.({ limit, offset });
39
+ // -> [{ key, createdAt, blockExpiresAt, ttlMs, metadata }]
40
+
41
+ store.getStoreInfo?.();
42
+ // -> { type, supportsAdminListing, ...metadata }
43
+
44
+ store.close?.();
45
+ ```
46
+
47
+ `key` is produced by the rate limiter, route policy, or brute force key builder.
48
+ Stores are responsible for TTL handling, cleanup, and any backend-specific
49
+ namespacing. Custom stores should avoid persisting sensitive request data.
50
+ Administrative listing methods are optional, but stores that implement them must
51
+ return only sanitized metadata suitable for a read-only operations dashboard.
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+
3
+ const { MemoryStore } = require('./memory-store');
4
+ const { RedisStore } = require('./redis-store');
5
+
6
+ module.exports = { MemoryStore, RedisStore };
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ class MemoryStore {
4
+ constructor() {
5
+ this.rateLimits = new Map();
6
+ this.bans = new Map();
7
+ this.suspicious = new Map();
8
+ this.counters = new Map();
9
+ this.blocks = new Map();
10
+ }
11
+
12
+ incrementRateLimit(key, windowMs) {
13
+ const now = Date.now();
14
+ const normalizedKey = normalizeKey(key);
15
+ const windowStart = now - windowMs;
16
+ const entry = this.rateLimits.get(normalizedKey) || { timestamps: [] };
17
+
18
+ entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > windowStart);
19
+ entry.timestamps.push(now);
20
+ entry.resetAt = entry.timestamps[0] + windowMs;
21
+ this.rateLimits.set(normalizedKey, entry);
22
+
23
+ return formatCounterResult(normalizedKey, entry.timestamps.length, entry.resetAt, now);
24
+ }
25
+
26
+ getRateLimit(key) {
27
+ const now = Date.now();
28
+ const normalizedKey = normalizeKey(key);
29
+ const entry = this.rateLimits.get(normalizedKey);
30
+ if (!entry) return emptyCounterResult(normalizedKey);
31
+
32
+ const resetAt = entry.resetAt || now;
33
+ if (entry.timestamps.length === 0 || resetAt <= now) {
34
+ this.rateLimits.delete(normalizedKey);
35
+ return emptyCounterResult(normalizedKey);
36
+ }
37
+
38
+ return formatCounterResult(normalizedKey, entry.timestamps.length, resetAt, now);
39
+ }
40
+
41
+ resetRateLimit(key) {
42
+ return this.rateLimits.delete(normalizeKey(key));
43
+ }
44
+
45
+ ban(key, ttlMs, metadata = {}) {
46
+ const normalizedKey = normalizeKey(key);
47
+ const createdAt = Date.now();
48
+ const expiresAt = Date.now() + ttlMs;
49
+ this.bans.set(normalizedKey, { createdAt, expiresAt, metadata });
50
+ return { key: normalizedKey, banned: true, createdAt, banExpiresAt: expiresAt, metadata };
51
+ }
52
+
53
+ isBanned(key) {
54
+ const normalizedKey = normalizeKey(key);
55
+ const entry = this.bans.get(normalizedKey);
56
+ if (!entry) return { key: normalizedKey, banned: false, banExpiresAt: null, metadata: null };
57
+
58
+ const now = Date.now();
59
+ if (entry.expiresAt <= now) {
60
+ this.bans.delete(normalizedKey);
61
+ this.suspicious.delete(normalizedKey);
62
+ this.rateLimits.delete(normalizedKey);
63
+ return { key: normalizedKey, banned: false, banExpiresAt: null, metadata: null };
64
+ }
65
+
66
+ return {
67
+ key: normalizedKey,
68
+ banned: true,
69
+ createdAt: entry.createdAt || null,
70
+ banExpiresAt: entry.expiresAt,
71
+ metadata: entry.metadata || null,
72
+ };
73
+ }
74
+
75
+ unban(key) {
76
+ const normalizedKey = normalizeKey(key);
77
+ this.suspicious.delete(normalizedKey);
78
+ return this.bans.delete(normalizedKey);
79
+ }
80
+
81
+ recordSuspicious(key, ttlMs, metadata = {}) {
82
+ const now = Date.now();
83
+ const normalizedKey = normalizeKey(key);
84
+ const current = this.suspicious.get(normalizedKey);
85
+ const entry =
86
+ current && current.resetAt > now
87
+ ? current
88
+ : { count: 0, resetAt: now + ttlMs, metadata: null };
89
+
90
+ entry.count += 1;
91
+ entry.metadata = metadata;
92
+ this.suspicious.set(normalizedKey, entry);
93
+
94
+ return formatCounterResult(normalizedKey, entry.count, entry.resetAt, now);
95
+ }
96
+
97
+ incrementCounter(key, ttlMs, metadata = {}) {
98
+ const now = Date.now();
99
+ const normalizedKey = normalizeKey(key);
100
+ const current = this.counters.get(normalizedKey);
101
+ const entry =
102
+ current && current.resetAt > now
103
+ ? current
104
+ : { count: 0, resetAt: now + ttlMs, metadata: null };
105
+
106
+ entry.count += 1;
107
+ entry.metadata = metadata;
108
+ this.counters.set(normalizedKey, entry);
109
+
110
+ return formatCounterResult(normalizedKey, entry.count, entry.resetAt, now);
111
+ }
112
+
113
+ getCounter(key) {
114
+ const now = Date.now();
115
+ const normalizedKey = normalizeKey(key);
116
+ const entry = this.counters.get(normalizedKey);
117
+ if (!entry || entry.resetAt <= now) {
118
+ this.counters.delete(normalizedKey);
119
+ return emptyCounterResult(normalizedKey);
120
+ }
121
+
122
+ return formatCounterResult(normalizedKey, entry.count, entry.resetAt, now);
123
+ }
124
+
125
+ resetCounter(key) {
126
+ return this.counters.delete(normalizeKey(key));
127
+ }
128
+
129
+ blockKey(key, ttlMs, metadata = {}) {
130
+ const normalizedKey = normalizeKey(key);
131
+ const createdAt = Date.now();
132
+ const blockExpiresAt = Date.now() + ttlMs;
133
+ this.blocks.set(normalizedKey, { createdAt, blockExpiresAt, metadata });
134
+ return { key: normalizedKey, blocked: true, createdAt, blockExpiresAt, metadata };
135
+ }
136
+
137
+ isBlocked(key) {
138
+ const normalizedKey = normalizeKey(key);
139
+ const entry = this.blocks.get(normalizedKey);
140
+ if (!entry) return { key: normalizedKey, blocked: false, blockExpiresAt: null, metadata: null };
141
+
142
+ const now = Date.now();
143
+ if (entry.blockExpiresAt <= now) {
144
+ this.blocks.delete(normalizedKey);
145
+ return { key: normalizedKey, blocked: false, blockExpiresAt: null, metadata: null };
146
+ }
147
+
148
+ return {
149
+ key: normalizedKey,
150
+ blocked: true,
151
+ createdAt: entry.createdAt || null,
152
+ blockExpiresAt: entry.blockExpiresAt,
153
+ metadata: entry.metadata || null,
154
+ };
155
+ }
156
+
157
+ unblockKey(key) {
158
+ return this.blocks.delete(normalizeKey(key));
159
+ }
160
+
161
+ cleanup(now = Date.now()) {
162
+ for (const [key, entry] of this.rateLimits.entries()) {
163
+ if (!entry.timestamps.length) {
164
+ this.rateLimits.delete(key);
165
+ continue;
166
+ }
167
+
168
+ if (entry.resetAt <= now) this.rateLimits.delete(key);
169
+ }
170
+
171
+ for (const [key, entry] of this.bans.entries()) {
172
+ if (entry.expiresAt <= now) this.bans.delete(key);
173
+ }
174
+
175
+ for (const [key, entry] of this.suspicious.entries()) {
176
+ if (entry.resetAt <= now) this.suspicious.delete(key);
177
+ }
178
+
179
+ for (const [key, entry] of this.counters.entries()) {
180
+ if (entry.resetAt <= now) this.counters.delete(key);
181
+ }
182
+
183
+ for (const [key, entry] of this.blocks.entries()) {
184
+ if (entry.blockExpiresAt <= now) this.blocks.delete(key);
185
+ }
186
+ }
187
+
188
+ snapshot(windowMs) {
189
+ const now = Date.now();
190
+ const ips = new Set([
191
+ ...this.rateLimits.keys(),
192
+ ...this.suspicious.keys(),
193
+ ...this.bans.keys(),
194
+ ]);
195
+
196
+ return [...ips].map((ip) => {
197
+ const rateLimitEntry = this.rateLimits.get(ip);
198
+ const suspiciousEntry = this.suspicious.get(ip);
199
+ const ban = this.isBanned(ip);
200
+ const windowStart = now - windowMs;
201
+ const requests = rateLimitEntry
202
+ ? rateLimitEntry.timestamps.filter((timestamp) => timestamp > windowStart).length
203
+ : 0;
204
+
205
+ return {
206
+ ip,
207
+ requests,
208
+ suspicious: suspiciousEntry && suspiciousEntry.resetAt > now ? suspiciousEntry.count : 0,
209
+ banned: ban.banned,
210
+ banExpiresAt: ban.banExpiresAt,
211
+ };
212
+ });
213
+ }
214
+
215
+ listBans() {
216
+ const now = Date.now();
217
+ this.cleanup(now);
218
+
219
+ return [...this.bans.entries()].map(([key, entry]) => ({
220
+ key,
221
+ createdAt: entry.createdAt || null,
222
+ banExpiresAt: entry.expiresAt,
223
+ ttlMs: Math.max(0, entry.expiresAt - now),
224
+ metadata: entry.metadata || null,
225
+ }));
226
+ }
227
+
228
+ listBlocks() {
229
+ const now = Date.now();
230
+ this.cleanup(now);
231
+
232
+ return [...this.blocks.entries()].map(([key, entry]) => ({
233
+ key,
234
+ createdAt: entry.createdAt || null,
235
+ blockExpiresAt: entry.blockExpiresAt,
236
+ ttlMs: Math.max(0, entry.blockExpiresAt - now),
237
+ metadata: entry.metadata || null,
238
+ }));
239
+ }
240
+
241
+ getStoreInfo() {
242
+ return {
243
+ type: 'memory',
244
+ supportsAdminListing: true,
245
+ };
246
+ }
247
+
248
+ clear() {
249
+ this.rateLimits.clear();
250
+ this.bans.clear();
251
+ this.suspicious.clear();
252
+ this.counters.clear();
253
+ this.blocks.clear();
254
+ }
255
+
256
+ close() {
257
+ this.clear();
258
+ }
259
+ }
260
+
261
+ function normalizeKey(key) {
262
+ return String(key || 'unknown');
263
+ }
264
+
265
+ function emptyCounterResult(key) {
266
+ return { key, count: 0, resetAt: null, ttlMs: 0 };
267
+ }
268
+
269
+ function formatCounterResult(key, count, resetAt, now) {
270
+ return {
271
+ key,
272
+ count,
273
+ resetAt,
274
+ ttlMs: Math.max(0, resetAt - now),
275
+ };
276
+ }
277
+
278
+ module.exports = { MemoryStore };