@chdlabs/my-shield 0.1.3 → 0.3.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,304 @@
1
+ /**
2
+ * @file src/middleware/behavioralGuard.js
3
+ * Behavioral Biometrics Express Middleware
4
+ * Evaluates pointer kinematics telemetry against MiniRocket + Dual Ridge model.
5
+ *
6
+ * @author ChdLabs
7
+ * @license MIT
8
+ */
9
+
10
+ import crypto from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { MiniRocket } from '../biometrics/minirocket.js';
15
+ import { DualRidgeClassifier } from '../biometrics/ridge.js';
16
+ import { LRUCache } from '../core/lruCache.js';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+
21
+ export class BehavioralShieldService {
22
+ /**
23
+ * @param {Object} [options={}]
24
+ * @param {Object} [options.modelWeights] Custom model weights { thresholds, weights, bias }
25
+ * @param {Object} [options.shield] Shield instance
26
+ * @param {Object} [options.logger=console] Logger instance
27
+ */
28
+ constructor({ modelWeights = null, shield = null, logger = console } = {}) {
29
+ this.shield = shield;
30
+ this.logger = logger;
31
+
32
+ let weights = modelWeights;
33
+ if (!weights) {
34
+ try {
35
+ const defaultWeightsPath = path.resolve(__dirname, '../../models/default-weights.json');
36
+ if (fs.existsSync(defaultWeightsPath)) {
37
+ weights = JSON.parse(fs.readFileSync(defaultWeightsPath, 'utf8'));
38
+ }
39
+ } catch (err) {
40
+ this.logger?.warn?.('[SHIELD:BIOMETRICS] Could not load default-weights.json:', err.message);
41
+ }
42
+ }
43
+
44
+ const featureCount = weights?.weights?.length || 336;
45
+ const windowLength = weights?.windowLength || 128;
46
+
47
+ this.transformer = new MiniRocket(windowLength, featureCount);
48
+ if (weights?.thresholds) {
49
+ this.transformer.loadThresholds(weights.thresholds);
50
+ }
51
+
52
+ this.classifier = new DualRidgeClassifier();
53
+ if (weights?.weights) {
54
+ this.classifier.loadWeights(weights.weights, weights.bias || 0.0);
55
+ }
56
+
57
+ // Bounded LRU cache for replay nonce protection (eliminates memory leaks)
58
+ this.nonceCache = new LRUCache({
59
+ maxSize: 10_000,
60
+ ttlMs: 60_000
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Verify a 128-sample velocity vector
66
+ * @param {number[]|Float64Array} velocityArray
67
+ * @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number }}
68
+ */
69
+ verify(velocityArray) {
70
+ const t0 = performance.now();
71
+ const series = Float64Array.from(velocityArray);
72
+ const features = this.transformer.transform(series);
73
+ const result = this.classifier.predict(features);
74
+ const latency = +(performance.now() - t0).toFixed(2);
75
+
76
+ return { ...result, latencyMs: latency };
77
+ }
78
+
79
+ /**
80
+ * Extract client IP safely from headers or socket
81
+ * @param {Object} req
82
+ * @returns {string}
83
+ */
84
+ getClientIP(req) {
85
+ if (this.shield?.ipResolver) {
86
+ return this.shield.ipResolver.resolve(req);
87
+ }
88
+ const forwarded = req.headers['x-forwarded-for'];
89
+ if (forwarded && typeof forwarded === 'string') {
90
+ const first = forwarded.split(',')[0].trim();
91
+ if (first) return first;
92
+ }
93
+ return req.socket?.remoteAddress || req.ip || '127.0.0.1';
94
+ }
95
+
96
+ /**
97
+ * Express middleware factory
98
+ * @param {Object} [options={}]
99
+ * @param {number} [options.threshold=0.50] Probability threshold for human classification (calibrated to Ridge boundary)
100
+ * @param {'block'|'audit'|'header-only'} [options.mode='block'] Execution mode: 'block' rejects bots, 'audit' logs and sets headers, 'header-only' sets headers
101
+ * @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
102
+ * @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
103
+ * @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
104
+ * @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
105
+ * @param {Function} [options.onBotDetected] Custom callback for bot detection (in 'block' mode)
106
+ * @param {Function} [options.onVerdict] Callback invoked for all outcomes: (verdict, req, res) => void
107
+ */
108
+ middleware(options = {}) {
109
+ const {
110
+ threshold = 0.50,
111
+ mode = 'block',
112
+ headerName = 'x-shield-telemetry',
113
+ replayWindowMs = 15_000,
114
+ allowFallbackOnError = false,
115
+ autoBlockIP = false,
116
+ onBotDetected,
117
+ onVerdict
118
+ } = options;
119
+
120
+ return (req, res, next) => {
121
+ const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
122
+
123
+ const handleVerdict = (verdict) => {
124
+ if (typeof onVerdict === 'function') {
125
+ try {
126
+ onVerdict(verdict, req, res);
127
+ } catch (e) {
128
+ this.logger?.error?.('[SHIELD:BIOMETRICS] Error in onVerdict callback:', e.message);
129
+ }
130
+ }
131
+ };
132
+
133
+ const setVerdictHeader = (isHuman, confidence, score, reason) => {
134
+ if (res.headersSent) return;
135
+ const headerVal = isHuman
136
+ ? `HUMAN; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}`
137
+ : `BOT; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}; reason=${reason || 'AUTOMATED_KINEMATICS_DETECTED'}`;
138
+ res.setHeader('X-Shield-Verdict', headerVal);
139
+ };
140
+
141
+ if (!rawHeader) {
142
+ if (mode === 'audit' || mode === 'header-only') {
143
+ if (!res.headersSent) {
144
+ res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MISSING_TELEMETRY');
145
+ }
146
+ req.shield = req.shield || {};
147
+ req.shield.biometrics = { isHuman: false, missingTelemetry: true };
148
+ handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
149
+ return next();
150
+ }
151
+
152
+ if (allowFallbackOnError) return next();
153
+ handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
154
+ return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
155
+ }
156
+
157
+ try {
158
+ const payload = typeof rawHeader === 'string' ? JSON.parse(rawHeader) : rawHeader;
159
+
160
+ // 1. Synthetic Dispatch Check
161
+ if (payload.isTrusted === false) {
162
+ if (mode === 'audit' || mode === 'header-only') {
163
+ setVerdictHeader(false, 0, -99, 'SYNTHETIC_EVENT_DISPATCH');
164
+ req.shield = req.shield || {};
165
+ req.shield.biometrics = { isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH' };
166
+ handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
167
+ return next();
168
+ }
169
+ if (autoBlockIP && this.shield) {
170
+ const ip = this.getClientIP(req);
171
+ this.shield.blockIP(ip, 'behavioral_bot_detected', { reason: 'SYNTHETIC_EVENT_DISPATCH' });
172
+ }
173
+ handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
174
+ return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
175
+ }
176
+
177
+ // 2. Replay Protection Window Check
178
+ const now = Date.now();
179
+ if (!payload.timestamp || typeof payload.timestamp !== 'number' || Math.abs(now - payload.timestamp) > replayWindowMs) {
180
+ if (mode === 'audit' || mode === 'header-only') {
181
+ setVerdictHeader(false, 0, -99, 'EXPIRED_OR_FUTURE_TELEMETRY');
182
+ req.shield = req.shield || {};
183
+ req.shield.biometrics = { isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY' };
184
+ handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
185
+ return next();
186
+ }
187
+ handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
188
+ return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
189
+ }
190
+
191
+ // 3. Payload Schema Validation
192
+ const isMalformed = !Array.isArray(payload.velocity) ||
193
+ payload.velocity.length !== this.transformer.windowLength ||
194
+ payload.velocity.some(v => typeof v !== 'number' || !Number.isFinite(v));
195
+
196
+ if (isMalformed) {
197
+ if (mode === 'audit' || mode === 'header-only') {
198
+ setVerdictHeader(false, 0, -99, 'MALFORMED_KINEMATICS_VECTOR');
199
+ req.shield = req.shield || {};
200
+ req.shield.biometrics = { isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR' };
201
+ handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
202
+ return next();
203
+ }
204
+ handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
205
+ return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
206
+ }
207
+
208
+ // 4. Coordinate Signature Nonce Check
209
+ const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
210
+ if (this.nonceCache.has(nonce)) {
211
+ if (mode === 'audit' || mode === 'header-only') {
212
+ setVerdictHeader(false, 0, -99, 'REPLAY_ATTACK_DETECTED');
213
+ req.shield = req.shield || {};
214
+ req.shield.biometrics = { isHuman: false, reason: 'REPLAY_ATTACK_DETECTED' };
215
+ handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
216
+ return next();
217
+ }
218
+ handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
219
+ return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
220
+ }
221
+ this.nonceCache.set(nonce, true, replayWindowMs);
222
+
223
+ // 5. MiniRocket & Dual Ridge Scoring
224
+ const outcome = this.verify(payload.velocity);
225
+ const ip = this.getClientIP(req);
226
+ const isHuman = outcome.probability >= threshold;
227
+
228
+ req.shield = req.shield || {};
229
+ req.shield.biometrics = {
230
+ isHuman,
231
+ humanConfidence: outcome.probability,
232
+ latencyMs: outcome.latencyMs,
233
+ score: outcome.score
234
+ };
235
+
236
+ setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null : 'AUTOMATED_KINEMATICS_DETECTED');
237
+
238
+ const verdictMeta = {
239
+ isHuman,
240
+ probability: outcome.probability,
241
+ score: outcome.score,
242
+ latencyMs: outcome.latencyMs,
243
+ ip,
244
+ mode
245
+ };
246
+ handleVerdict(verdictMeta);
247
+
248
+ // Record event in Shield core telemetry ring buffer
249
+ if (this.shield) {
250
+ this.shield.recordEvent({
251
+ type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
252
+ ip,
253
+ isHuman,
254
+ confidence: outcome.probability,
255
+ score: outcome.score,
256
+ latencyMs: outcome.latencyMs,
257
+ mode
258
+ });
259
+ }
260
+
261
+ if (mode === 'audit' || mode === 'header-only') {
262
+ return next();
263
+ }
264
+
265
+ // mode === 'block'
266
+ if (!isHuman) {
267
+ if (autoBlockIP && this.shield) {
268
+ this.shield.blockIP(ip, 'behavioral_bot_detected', {
269
+ confidence: outcome.probability,
270
+ latencyMs: outcome.latencyMs,
271
+ score: outcome.score
272
+ });
273
+ }
274
+ return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
275
+ }
276
+
277
+ next();
278
+ } catch (err) {
279
+ if (mode === 'audit' || mode === 'header-only' || allowFallbackOnError) {
280
+ if (!res.headersSent) {
281
+ res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MALFORMED_TELEMETRY');
282
+ }
283
+ return next();
284
+ }
285
+ res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
286
+ }
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Handle blocked request
292
+ * @private
293
+ */
294
+ _deny(req, res, reason, customHandler, meta = {}) {
295
+ if (customHandler && typeof customHandler === 'function') {
296
+ return customHandler(req, res, { reason, ...meta });
297
+ }
298
+ return res.status(403).json({
299
+ status: 'BLOCKED',
300
+ shieldCode: reason,
301
+ details: meta
302
+ });
303
+ }
304
+ }
@@ -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;