@chdlabs/my-shield 0.2.0 → 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.
@@ -82,6 +82,9 @@ export class BehavioralShieldService {
82
82
  * @returns {string}
83
83
  */
84
84
  getClientIP(req) {
85
+ if (this.shield?.ipResolver) {
86
+ return this.shield.ipResolver.resolve(req);
87
+ }
85
88
  const forwarded = req.headers['x-forwarded-for'];
86
89
  if (forwarded && typeof forwarded === 'string') {
87
90
  const first = forwarded.split(',')[0].trim();
@@ -93,28 +96,61 @@ export class BehavioralShieldService {
93
96
  /**
94
97
  * Express middleware factory
95
98
  * @param {Object} [options={}]
96
- * @param {number} [options.threshold=0.70] Probability threshold for human classification
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
97
101
  * @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
98
102
  * @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
99
- * @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed
103
+ * @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
100
104
  * @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
101
- * @param {Function} [options.onBotDetected] Custom callback for bot detection
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
102
107
  */
103
108
  middleware(options = {}) {
104
109
  const {
105
- threshold = 0.70,
110
+ threshold = 0.50,
111
+ mode = 'block',
106
112
  headerName = 'x-shield-telemetry',
107
113
  replayWindowMs = 15_000,
108
114
  allowFallbackOnError = false,
109
115
  autoBlockIP = false,
110
- onBotDetected
116
+ onBotDetected,
117
+ onVerdict
111
118
  } = options;
112
119
 
113
120
  return (req, res, next) => {
114
121
  const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
115
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
+
116
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
+
117
152
  if (allowFallbackOnError) return next();
153
+ handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
118
154
  return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
119
155
  }
120
156
 
@@ -123,30 +159,63 @@ export class BehavioralShieldService {
123
159
 
124
160
  // 1. Synthetic Dispatch Check
125
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 });
126
174
  return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
127
175
  }
128
176
 
129
177
  // 2. Replay Protection Window Check
130
178
  const now = Date.now();
131
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 });
132
188
  return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
133
189
  }
134
190
 
135
191
  // 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
- }
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));
139
195
 
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);
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();
144
203
  }
204
+ handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
205
+ return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
145
206
  }
146
207
 
147
208
  // 4. Coordinate Signature Nonce Check
148
209
  const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
149
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 });
150
219
  return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
151
220
  }
152
221
  this.nonceCache.set(nonce, true, replayWindowMs);
@@ -154,31 +223,52 @@ export class BehavioralShieldService {
154
223
  // 5. MiniRocket & Dual Ridge Scoring
155
224
  const outcome = this.verify(payload.velocity);
156
225
  const ip = this.getClientIP(req);
226
+ const isHuman = outcome.probability >= threshold;
157
227
 
158
228
  req.shield = req.shield || {};
159
229
  req.shield.biometrics = {
160
- isHuman: outcome.probability >= threshold,
230
+ isHuman,
161
231
  humanConfidence: outcome.probability,
162
232
  latencyMs: outcome.latencyMs,
163
233
  score: outcome.score
164
234
  };
165
235
 
166
- // Record event in Shield core if attached
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
167
249
  if (this.shield) {
168
250
  this.shield.recordEvent({
169
- type: 'BIOMETRICS_VERIFIED',
251
+ type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
170
252
  ip,
171
- isHuman: outcome.probability >= threshold,
253
+ isHuman,
172
254
  confidence: outcome.probability,
173
- latencyMs: outcome.latencyMs
255
+ score: outcome.score,
256
+ latencyMs: outcome.latencyMs,
257
+ mode
174
258
  });
175
259
  }
176
260
 
177
- if (outcome.probability < threshold) {
261
+ if (mode === 'audit' || mode === 'header-only') {
262
+ return next();
263
+ }
264
+
265
+ // mode === 'block'
266
+ if (!isHuman) {
178
267
  if (autoBlockIP && this.shield) {
179
268
  this.shield.blockIP(ip, 'behavioral_bot_detected', {
180
269
  confidence: outcome.probability,
181
- latencyMs: outcome.latencyMs
270
+ latencyMs: outcome.latencyMs,
271
+ score: outcome.score
182
272
  });
183
273
  }
184
274
  return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
@@ -186,7 +276,12 @@ export class BehavioralShieldService {
186
276
 
187
277
  next();
188
278
  } catch (err) {
189
- if (allowFallbackOnError) return next();
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
+ }
190
285
  res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
191
286
  }
192
287
  };
@@ -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;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * In-Memory Storage Adapter
3
+ *
4
+ * Default ephemeral adapter for non-clustered, single-process deployments
5
+ * with zero filesystem or network overhead.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+ import { BaseStorageAdapter } from './BaseAdapter.js';
11
+
12
+ export class MemoryAdapter extends BaseStorageAdapter {
13
+ constructor() {
14
+ super();
15
+ this.records = new Map();
16
+ }
17
+
18
+ async init(shield) {
19
+ await super.init(shield);
20
+ }
21
+
22
+ async loadBlockedIPs() {
23
+ const now = Date.now();
24
+ const active = [];
25
+ for (const [ip, item] of this.records.entries()) {
26
+ if (item.expiresAt && item.expiresAt <= now) {
27
+ this.records.delete(ip);
28
+ } else {
29
+ active.push(item);
30
+ }
31
+ }
32
+ return active;
33
+ }
34
+
35
+ async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
36
+ if (!ip) return;
37
+ this.records.set(ip, {
38
+ ip,
39
+ reason,
40
+ metadata,
41
+ expiresAt,
42
+ savedAt: Date.now()
43
+ });
44
+ }
45
+
46
+ async removeBlockedIP(ip) {
47
+ if (!ip) return;
48
+ this.records.delete(ip);
49
+ }
50
+
51
+ async close() {
52
+ this.records.clear();
53
+ }
54
+ }
55
+
56
+ export default MemoryAdapter;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Storage Subsystem Exports & Factory
3
+ *
4
+ * @author ChdLabs
5
+ * @license MIT
6
+ */
7
+ import { BaseStorageAdapter } from './BaseAdapter.js';
8
+ import { MemoryAdapter } from './MemoryAdapter.js';
9
+ import { FileStorageAdapter } from './FileStorageAdapter.js';
10
+ import { CallbackStorageAdapter } from './CallbackStorageAdapter.js';
11
+
12
+ export {
13
+ BaseStorageAdapter,
14
+ MemoryAdapter,
15
+ FileStorageAdapter,
16
+ CallbackStorageAdapter
17
+ };
18
+
19
+ /**
20
+ * Instantiate appropriate storage adapter based on options
21
+ * @param {string|Object|BaseStorageAdapter} [storageOption]
22
+ * @param {Object} [logger]
23
+ * @returns {BaseStorageAdapter}
24
+ */
25
+ export function createStorageAdapter(storageOption, logger = console) {
26
+ if (!storageOption || storageOption === 'memory') {
27
+ return new MemoryAdapter();
28
+ }
29
+
30
+ if (storageOption instanceof BaseStorageAdapter) {
31
+ return storageOption;
32
+ }
33
+
34
+ if (typeof storageOption === 'object') {
35
+ // Callback adapter: has onLoad, onBlock, or onUnblock
36
+ if (storageOption.onLoad || storageOption.onBlock || storageOption.onUnblock) {
37
+ return new CallbackStorageAdapter({ ...storageOption, logger });
38
+ }
39
+
40
+ // File adapter: has type === 'file' or directory or filename
41
+ if (storageOption.type === 'file' || storageOption.directory || storageOption.filename) {
42
+ return new FileStorageAdapter({ ...storageOption, logger });
43
+ }
44
+ }
45
+
46
+ if (storageOption === 'file') {
47
+ return new FileStorageAdapter({ logger });
48
+ }
49
+
50
+ return new MemoryAdapter();
51
+ }
52
+
53
+ export default createStorageAdapter;