@chdlabs/my-shield 0.2.0 → 0.3.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.
@@ -76,12 +76,58 @@ export class BehavioralShieldService {
76
76
  return { ...result, latencyMs: latency };
77
77
  }
78
78
 
79
+ /**
80
+ * Evaluate mobile tap dwell kinetics against human physiological tap distribution
81
+ * @param {number} dwellMs Tap contact duration in milliseconds
82
+ * @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number, reason?: string }}
83
+ */
84
+ verifyTapDwell(dwellMs) {
85
+ const t0 = performance.now();
86
+ if (typeof dwellMs !== 'number' || !Number.isFinite(dwellMs) || dwellMs < 20) {
87
+ return {
88
+ score: -2.5,
89
+ probability: 0.05,
90
+ isHuman: false,
91
+ latencyMs: +(performance.now() - t0).toFixed(2),
92
+ reason: 'BOT_INSTANT_TAP'
93
+ };
94
+ }
95
+ if (dwellMs > 1500) {
96
+ return {
97
+ score: -1.2,
98
+ probability: 0.20,
99
+ isHuman: false,
100
+ latencyMs: +(performance.now() - t0).toFixed(2),
101
+ reason: 'ABNORMAL_TAP_DWELL'
102
+ };
103
+ }
104
+
105
+ // Calibrated log-normal human tap distribution (mean = ~90ms, std = 0.55)
106
+ const logVal = Math.log(dwellMs);
107
+ const mean = Math.log(90);
108
+ const std = 0.55;
109
+ const z = (logVal - mean) / std;
110
+ const score = +(2.0 - 0.8 * Math.abs(z)).toFixed(2);
111
+ const probability = +(1 / (1 + Math.exp(-score))).toFixed(2);
112
+ const latency = +(performance.now() - t0).toFixed(2);
113
+
114
+ return {
115
+ score,
116
+ probability,
117
+ isHuman: probability >= 0.50,
118
+ latencyMs: latency
119
+ };
120
+ }
121
+
79
122
  /**
80
123
  * Extract client IP safely from headers or socket
81
124
  * @param {Object} req
82
125
  * @returns {string}
83
126
  */
84
127
  getClientIP(req) {
128
+ if (this.shield?.ipResolver) {
129
+ return this.shield.ipResolver.resolve(req);
130
+ }
85
131
  const forwarded = req.headers['x-forwarded-for'];
86
132
  if (forwarded && typeof forwarded === 'string') {
87
133
  const first = forwarded.split(',')[0].trim();
@@ -93,28 +139,61 @@ export class BehavioralShieldService {
93
139
  /**
94
140
  * Express middleware factory
95
141
  * @param {Object} [options={}]
96
- * @param {number} [options.threshold=0.70] Probability threshold for human classification
142
+ * @param {number} [options.threshold=0.50] Probability threshold for human classification (calibrated to Ridge boundary)
143
+ * @param {'block'|'audit'|'header-only'} [options.mode='block'] Execution mode: 'block' rejects bots, 'audit' logs and sets headers, 'header-only' sets headers
97
144
  * @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
98
145
  * @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
99
- * @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed
146
+ * @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
100
147
  * @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
101
- * @param {Function} [options.onBotDetected] Custom callback for bot detection
148
+ * @param {Function} [options.onBotDetected] Custom callback for bot detection (in 'block' mode)
149
+ * @param {Function} [options.onVerdict] Callback invoked for all outcomes: (verdict, req, res) => void
102
150
  */
103
151
  middleware(options = {}) {
104
152
  const {
105
- threshold = 0.70,
153
+ threshold = 0.50,
154
+ mode = 'block',
106
155
  headerName = 'x-shield-telemetry',
107
156
  replayWindowMs = 15_000,
108
157
  allowFallbackOnError = false,
109
158
  autoBlockIP = false,
110
- onBotDetected
159
+ onBotDetected,
160
+ onVerdict
111
161
  } = options;
112
162
 
113
163
  return (req, res, next) => {
114
164
  const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
115
165
 
166
+ const handleVerdict = (verdict) => {
167
+ if (typeof onVerdict === 'function') {
168
+ try {
169
+ onVerdict(verdict, req, res);
170
+ } catch (e) {
171
+ this.logger?.error?.('[SHIELD:BIOMETRICS] Error in onVerdict callback:', e.message);
172
+ }
173
+ }
174
+ };
175
+
176
+ const setVerdictHeader = (isHuman, confidence, score, reason) => {
177
+ if (res.headersSent || typeof res.setHeader !== 'function') return;
178
+ const headerVal = isHuman
179
+ ? `HUMAN; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}`
180
+ : `BOT; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}; reason=${reason || 'AUTOMATED_KINEMATICS_DETECTED'}`;
181
+ res.setHeader('X-Shield-Verdict', headerVal);
182
+ };
183
+
116
184
  if (!rawHeader) {
185
+ if (mode === 'audit' || mode === 'header-only') {
186
+ if (!res.headersSent) {
187
+ res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MISSING_TELEMETRY');
188
+ }
189
+ req.shield = req.shield || {};
190
+ req.shield.biometrics = { isHuman: false, missingTelemetry: true };
191
+ handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
192
+ return next();
193
+ }
194
+
117
195
  if (allowFallbackOnError) return next();
196
+ handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
118
197
  return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
119
198
  }
120
199
 
@@ -123,70 +202,139 @@ export class BehavioralShieldService {
123
202
 
124
203
  // 1. Synthetic Dispatch Check
125
204
  if (payload.isTrusted === false) {
205
+ if (mode === 'audit' || mode === 'header-only') {
206
+ setVerdictHeader(false, 0, -99, 'SYNTHETIC_EVENT_DISPATCH');
207
+ req.shield = req.shield || {};
208
+ req.shield.biometrics = { isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH' };
209
+ handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
210
+ return next();
211
+ }
212
+ if (autoBlockIP && this.shield) {
213
+ const ip = this.getClientIP(req);
214
+ this.shield.blockIP(ip, 'behavioral_bot_detected', { reason: 'SYNTHETIC_EVENT_DISPATCH' });
215
+ }
216
+ handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
126
217
  return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
127
218
  }
128
219
 
129
220
  // 2. Replay Protection Window Check
130
221
  const now = Date.now();
131
222
  if (!payload.timestamp || typeof payload.timestamp !== 'number' || Math.abs(now - payload.timestamp) > replayWindowMs) {
223
+ if (mode === 'audit' || mode === 'header-only') {
224
+ setVerdictHeader(false, 0, -99, 'EXPIRED_OR_FUTURE_TELEMETRY');
225
+ req.shield = req.shield || {};
226
+ req.shield.biometrics = { isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY' };
227
+ handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
228
+ return next();
229
+ }
230
+ handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
132
231
  return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
133
232
  }
134
233
 
135
234
  // 3. Payload Schema Validation
136
- if (!Array.isArray(payload.velocity) || payload.velocity.length !== this.transformer.windowLength) {
137
- return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
138
- }
235
+ const isMalformed = !Array.isArray(payload.velocity) ||
236
+ payload.velocity.length !== this.transformer.windowLength ||
237
+ payload.velocity.some(v => typeof v !== 'number' || !Number.isFinite(v));
139
238
 
140
- for (let i = 0; i < payload.velocity.length; i++) {
141
- const val = payload.velocity[i];
142
- if (typeof val !== 'number' || !Number.isFinite(val)) {
143
- return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
239
+ if (isMalformed) {
240
+ if (mode === 'audit' || mode === 'header-only') {
241
+ setVerdictHeader(false, 0, -99, 'MALFORMED_KINEMATICS_VECTOR');
242
+ req.shield = req.shield || {};
243
+ req.shield.biometrics = { isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR' };
244
+ handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
245
+ return next();
144
246
  }
247
+ handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
248
+ return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
145
249
  }
146
250
 
147
251
  // 4. Coordinate Signature Nonce Check
148
252
  const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
149
253
  if (this.nonceCache.has(nonce)) {
254
+ if (mode === 'audit' || mode === 'header-only') {
255
+ setVerdictHeader(false, 0, -99, 'REPLAY_ATTACK_DETECTED');
256
+ req.shield = req.shield || {};
257
+ req.shield.biometrics = { isHuman: false, reason: 'REPLAY_ATTACK_DETECTED' };
258
+ handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
259
+ return next();
260
+ }
261
+ handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
150
262
  return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
151
263
  }
152
264
  this.nonceCache.set(nonce, true, replayWindowMs);
153
265
 
154
- // 5. MiniRocket & Dual Ridge Scoring
155
- const outcome = this.verify(payload.velocity);
266
+ // 5. Kinematics Scoring (MiniRocket or Touch Tap Dwell)
267
+ const isTap = payload.source === 'tap' && typeof payload.dwellMs === 'number';
268
+ const outcome = isTap ? this.verifyTapDwell(payload.dwellMs) : this.verify(payload.velocity);
156
269
  const ip = this.getClientIP(req);
270
+ const isHuman = outcome.probability >= threshold;
271
+ const failReason = outcome.reason || 'AUTOMATED_KINEMATICS_DETECTED';
157
272
 
158
273
  req.shield = req.shield || {};
159
274
  req.shield.biometrics = {
160
- isHuman: outcome.probability >= threshold,
275
+ isHuman,
161
276
  humanConfidence: outcome.probability,
162
277
  latencyMs: outcome.latencyMs,
163
- score: outcome.score
278
+ score: outcome.score,
279
+ pointerType: payload.pointerType || 'mouse',
280
+ source: payload.source || 'trajectory'
164
281
  };
165
282
 
166
- // Record event in Shield core if attached
283
+ setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null : failReason);
284
+
285
+ const verdictMeta = {
286
+ isHuman,
287
+ probability: outcome.probability,
288
+ score: outcome.score,
289
+ latencyMs: outcome.latencyMs,
290
+ pointerType: payload.pointerType || 'mouse',
291
+ source: payload.source || 'trajectory',
292
+ ip,
293
+ mode
294
+ };
295
+ handleVerdict(verdictMeta);
296
+
297
+ // Record event in Shield core telemetry ring buffer
167
298
  if (this.shield) {
168
299
  this.shield.recordEvent({
169
- type: 'BIOMETRICS_VERIFIED',
300
+ type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
170
301
  ip,
171
- isHuman: outcome.probability >= threshold,
302
+ isHuman,
172
303
  confidence: outcome.probability,
173
- latencyMs: outcome.latencyMs
304
+ score: outcome.score,
305
+ latencyMs: outcome.latencyMs,
306
+ pointerType: payload.pointerType || 'mouse',
307
+ source: payload.source || 'trajectory',
308
+ mode
174
309
  });
175
310
  }
176
311
 
177
- if (outcome.probability < threshold) {
312
+ if (mode === 'audit' || mode === 'header-only') {
313
+ return next();
314
+ }
315
+
316
+ // mode === 'block'
317
+ if (!isHuman) {
178
318
  if (autoBlockIP && this.shield) {
179
319
  this.shield.blockIP(ip, 'behavioral_bot_detected', {
180
320
  confidence: outcome.probability,
181
- latencyMs: outcome.latencyMs
321
+ latencyMs: outcome.latencyMs,
322
+ score: outcome.score,
323
+ reason: failReason,
324
+ pointerType: payload.pointerType || 'mouse'
182
325
  });
183
326
  }
184
- return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
327
+ return this._deny(req, res, failReason, onBotDetected, outcome);
185
328
  }
186
329
 
187
330
  next();
188
331
  } catch (err) {
189
- if (allowFallbackOnError) return next();
332
+ if (mode === 'audit' || mode === 'header-only' || allowFallbackOnError) {
333
+ if (!res.headersSent) {
334
+ res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MALFORMED_TELEMETRY');
335
+ }
336
+ return next();
337
+ }
190
338
  res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
191
339
  }
192
340
  };
@@ -171,10 +171,12 @@ export class Honeypot {
171
171
  }
172
172
 
173
173
  // Extract client IP
174
- const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
175
- req.socket?.remoteAddress ||
176
- req.ip ||
177
- '127.0.0.1';
174
+ const ip = this.shield?.ipResolver
175
+ ? this.shield.ipResolver.resolve(req)
176
+ : ((req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
177
+ req.socket?.remoteAddress ||
178
+ req.ip ||
179
+ '127.0.0.1');
178
180
 
179
181
  const matchedPath = this.normalizePath(req.path);
180
182
  const meta = this.routeMetadata.get(matchedPath);
@@ -62,10 +62,12 @@ export class NotFoundCollector {
62
62
  * @param {Object} req Express request
63
63
  */
64
64
  handle404(req) {
65
- const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
66
- req.socket?.remoteAddress ||
67
- req.ip ||
68
- '127.0.0.1';
65
+ const ip = this.shield?.ipResolver
66
+ ? this.shield.ipResolver.resolve(req)
67
+ : ((req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
68
+ req.socket?.remoteAddress ||
69
+ req.ip ||
70
+ '127.0.0.1');
69
71
 
70
72
  const path = req.path || req.originalUrl || '/';
71
73
  const userAgent = req.headers['user-agent'] || '';
@@ -38,6 +38,9 @@ export class ShieldGuard {
38
38
  * @returns {string}
39
39
  */
40
40
  getClientIP(req) {
41
+ if (this.shield?.ipResolver) {
42
+ return this.shield.ipResolver.resolve(req);
43
+ }
41
44
  const forwarded = req.headers['x-forwarded-for'];
42
45
  if (forwarded && typeof forwarded === 'string') {
43
46
  const first = forwarded.split(',')[0].trim();
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Base Storage Adapter Interface
3
+ *
4
+ * Defines the contract for pluggable threat persistence across container
5
+ * restarts and distributed multi-instance deployments.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+
11
+ export class BaseStorageAdapter {
12
+ /**
13
+ * Initialize adapter with parent Shield instance
14
+ * @param {Object} shield
15
+ */
16
+ async init(shield) {
17
+ this.shield = shield;
18
+ }
19
+
20
+ /**
21
+ * Load persisted blocked IPs upon system initialization
22
+ * @returns {Promise<Array<{ ip: string, reason: string, metadata: object, expiresAt: number|null }>>}
23
+ */
24
+ async loadBlockedIPs() {
25
+ return [];
26
+ }
27
+
28
+ /**
29
+ * Persist a newly blocked IP threat
30
+ * @param {string} ip
31
+ * @param {string} reason
32
+ * @param {Object} [metadata={}]
33
+ * @param {number|null} [expiresAt=null]
34
+ * @returns {Promise<void>}
35
+ */
36
+ async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
37
+ // Subclasses implement persistence
38
+ }
39
+
40
+ /**
41
+ * Remove an IP threat from persistent storage (unblock)
42
+ * @param {string} ip
43
+ * @returns {Promise<void>}
44
+ */
45
+ async removeBlockedIP(ip) {
46
+ // Subclasses implement deletion
47
+ }
48
+
49
+ /**
50
+ * Clean up timers or connection handles on graceful shutdown
51
+ * @returns {Promise<void>}
52
+ */
53
+ async close() {
54
+ // Subclasses clean up resources
55
+ }
56
+ }
57
+
58
+ export default BaseStorageAdapter;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Callback Storage Adapter
3
+ *
4
+ * Universal bridge allowing developers to plug any database or caching layer
5
+ * (PostgreSQL, MySQL, Redis, MongoDB, DynamoDB, Prisma) into Shield
6
+ * via simple async function callbacks.
7
+ *
8
+ * @author ChdLabs
9
+ * @license MIT
10
+ */
11
+ import { BaseStorageAdapter } from './BaseAdapter.js';
12
+
13
+ export class CallbackStorageAdapter extends BaseStorageAdapter {
14
+ /**
15
+ * @param {Object} options
16
+ * @param {Function} [options.onLoad] Async function returning Array of { ip, reason, metadata, expiresAt }
17
+ * @param {Function} [options.onBlock] Async function called when an IP is blocked: (ip, reason, metadata, expiresAt)
18
+ * @param {Function} [options.onUnblock] Async function called when an IP is unblocked: (ip)
19
+ * @param {Object} [options.logger]
20
+ */
21
+ constructor(options = {}) {
22
+ super();
23
+ this.onLoad = options.onLoad || null;
24
+ this.onBlock = options.onBlock || null;
25
+ this.onUnblock = options.onUnblock || null;
26
+ this.logger = options.logger || console;
27
+ }
28
+
29
+ async init(shield) {
30
+ await super.init(shield);
31
+ }
32
+
33
+ async loadBlockedIPs() {
34
+ if (typeof this.onLoad !== 'function') {
35
+ return [];
36
+ }
37
+ try {
38
+ const result = await this.onLoad();
39
+ return Array.isArray(result) ? result : [];
40
+ } catch (err) {
41
+ this.logger.error?.('[SHIELD:STORAGE] Error in custom onLoad storage callback:', err.message);
42
+ return [];
43
+ }
44
+ }
45
+
46
+ async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
47
+ if (typeof this.onBlock !== 'function') return;
48
+ try {
49
+ await this.onBlock(ip, reason, metadata, expiresAt);
50
+ } catch (err) {
51
+ this.logger.error?.(`[SHIELD:STORAGE] Error in custom onBlock callback for ${ip}:`, err.message);
52
+ }
53
+ }
54
+
55
+ async removeBlockedIP(ip) {
56
+ if (typeof this.onUnblock !== 'function') return;
57
+ try {
58
+ await this.onUnblock(ip);
59
+ } catch (err) {
60
+ this.logger.error?.(`[SHIELD:STORAGE] Error in custom onUnblock callback for ${ip}:`, err.message);
61
+ }
62
+ }
63
+
64
+ async close() {
65
+ // No-op for callbacks unless custom teardown needed
66
+ }
67
+ }
68
+
69
+ export default CallbackStorageAdapter;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * File Storage Adapter
3
+ *
4
+ * Persists blocked IP threats and metadata to disk in JSON format.
5
+ * Employs atomic write operations (tempfile + rename) and debounced flushing
6
+ * to ensure integrity even during sudden container termination.
7
+ *
8
+ * @author ChdLabs
9
+ * @license MIT
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { BaseStorageAdapter } from './BaseAdapter.js';
14
+
15
+ export class FileStorageAdapter extends BaseStorageAdapter {
16
+ /**
17
+ * @param {Object} [options={}]
18
+ * @param {string} [options.directory='./data/shield']
19
+ * @param {string} [options.filename='blocked_ips.json']
20
+ * @param {number} [options.debounceMs=200]
21
+ * @param {Object} [options.logger]
22
+ */
23
+ constructor(options = {}) {
24
+ super();
25
+ const baseDir = options.directory || './data/shield';
26
+ const rawFileName = options.filename || 'blocked_ips.json';
27
+
28
+ // Sanitize path against directory traversal
29
+ const safeFileName = path.basename(rawFileName);
30
+ this.directory = path.resolve(process.cwd(), baseDir);
31
+ this.filePath = path.join(this.directory, safeFileName);
32
+ this.debounceMs = Math.max(0, options.debounceMs ?? 200);
33
+ this.logger = options.logger || console;
34
+
35
+ this.records = new Map();
36
+ this._flushTimer = null;
37
+ }
38
+
39
+ async init(shield) {
40
+ await super.init(shield);
41
+ this._ensureDirectory();
42
+ await this.loadBlockedIPs();
43
+ }
44
+
45
+ _ensureDirectory() {
46
+ try {
47
+ if (!fs.existsSync(this.directory)) {
48
+ fs.mkdirSync(this.directory, { recursive: true });
49
+ }
50
+ } catch (err) {
51
+ this.logger.error?.(`[SHIELD:STORAGE] Failed to create directory "${this.directory}":`, err.message);
52
+ }
53
+ }
54
+
55
+ async loadBlockedIPs() {
56
+ if (!fs.existsSync(this.filePath)) {
57
+ return [];
58
+ }
59
+
60
+ try {
61
+ const raw = fs.readFileSync(this.filePath, 'utf8');
62
+ if (!raw.trim()) return [];
63
+ const data = JSON.parse(raw);
64
+ const now = Date.now();
65
+ const active = [];
66
+
67
+ this.records.clear();
68
+
69
+ if (Array.isArray(data)) {
70
+ for (const item of data) {
71
+ if (item && item.ip) {
72
+ if (!item.expiresAt || item.expiresAt > now) {
73
+ this.records.set(item.ip, item);
74
+ active.push(item);
75
+ }
76
+ }
77
+ }
78
+ } else if (typeof data === 'object' && data !== null) {
79
+ // Object format: { "1.2.3.4": { reason, metadata, expiresAt } }
80
+ for (const [ip, item] of Object.entries(data)) {
81
+ if (item) {
82
+ const record = { ip, ...item };
83
+ if (!record.expiresAt || record.expiresAt > now) {
84
+ this.records.set(ip, record);
85
+ active.push(record);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ return active;
92
+ } catch (err) {
93
+ this.logger.error?.(`[SHIELD:STORAGE] Error loading blocked IPs from "${this.filePath}":`, err.message);
94
+ return [];
95
+ }
96
+ }
97
+
98
+ async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
99
+ if (!ip) return;
100
+ this.records.set(ip, {
101
+ ip,
102
+ reason,
103
+ metadata,
104
+ expiresAt,
105
+ savedAt: Date.now()
106
+ });
107
+ this._scheduleFlush();
108
+ }
109
+
110
+ async removeBlockedIP(ip) {
111
+ if (!ip) return;
112
+ if (this.records.delete(ip)) {
113
+ this._scheduleFlush();
114
+ }
115
+ }
116
+
117
+ _scheduleFlush() {
118
+ if (this.debounceMs === 0) {
119
+ this.flushSync();
120
+ return;
121
+ }
122
+
123
+ if (this._flushTimer) return;
124
+ this._flushTimer = setTimeout(() => {
125
+ this._flushTimer = null;
126
+ this.flushSync();
127
+ }, this.debounceMs);
128
+
129
+ if (typeof this._flushTimer.unref === 'function') {
130
+ this._flushTimer.unref();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Flush memory records to disk atomically
136
+ */
137
+ flushSync() {
138
+ if (this._flushTimer) {
139
+ clearTimeout(this._flushTimer);
140
+ this._flushTimer = null;
141
+ }
142
+
143
+ this._ensureDirectory();
144
+
145
+ const now = Date.now();
146
+ const exportArray = [];
147
+ for (const [ip, item] of this.records.entries()) {
148
+ if (item.expiresAt && item.expiresAt <= now) {
149
+ this.records.delete(ip);
150
+ } else {
151
+ exportArray.push(item);
152
+ }
153
+ }
154
+
155
+ const tempPath = `${this.filePath}.tmp.${Date.now()}`;
156
+ try {
157
+ fs.writeFileSync(tempPath, JSON.stringify(exportArray, null, 2), 'utf8');
158
+ fs.renameSync(tempPath, this.filePath);
159
+ } catch (err) {
160
+ this.logger.error?.(`[SHIELD:STORAGE] Failed writing blocked IPs to "${this.filePath}":`, err.message);
161
+ try {
162
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
163
+ } catch {
164
+ // Ignore cleanup error
165
+ }
166
+ }
167
+ }
168
+
169
+ async close() {
170
+ this.flushSync();
171
+ }
172
+ }
173
+
174
+ export default FileStorageAdapter;