@chiranthmoger/fortifyjs 1.1.0 → 1.1.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.
- package/README.md +231 -100
- package/bin/banner.js +29 -0
- package/bin/fortifyjs.js +12 -12
- package/index.d.ts +103 -3
- package/package.json +3 -2
- package/src/adapters/express.js +36 -21
- package/src/core/engine.js +63 -1
- package/src/core/normalizer.js +2 -2
- package/src/core/sinks.js +214 -0
- package/src/detectors/cmdi.js +7 -2
- package/src/detectors/open-redirect.js +10 -0
- package/src/detectors/path-traversal.js +17 -7
- package/src/detectors/prompt-injection.js +110 -0
- package/src/detectors/prototype-pollution.js +12 -2
- package/src/detectors/sqli.js +10 -0
- package/src/detectors/ssrf.js +202 -13
- package/src/detectors/template-injection.js +2 -2
- package/src/detectors/xss.js +22 -17
- package/src/index.js +34 -2
- package/src/presets.js +19 -1
- package/src/shields/csrf.js +10 -4
- package/src/shields/file-upload.js +5 -0
- package/src/shields/llm-guard.js +235 -0
- package/src/shields/rate-limiter.js +53 -11
- package/src/shields/sanitizer.js +113 -0
- package/src/shields/store.js +97 -0
- package/src/detectors/sqli.js.bak +0 -446
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const promptInjectionDetector = require('../detectors/prompt-injection');
|
|
4
|
+
const { matchSignals, combineConfidence } = require('../core/confidence');
|
|
5
|
+
const { Normalizer } = require('../core/normalizer');
|
|
6
|
+
|
|
7
|
+
class FortifyPromptError extends Error {
|
|
8
|
+
constructor(result, promptPreview = '') {
|
|
9
|
+
super(`FortifyJS: Prompt security violation detected (confidence: ${(result.confidence * 100).toFixed(1)}%)`);
|
|
10
|
+
this.name = 'FortifyPromptError';
|
|
11
|
+
this.code = 'FORTIFY_PROMPT_INJECTION';
|
|
12
|
+
this.status = 403;
|
|
13
|
+
this.result = result;
|
|
14
|
+
this.promptPreview = promptPreview.slice(0, 100);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extracts string text from various prompt shapes (string, chat message object, or message array)
|
|
20
|
+
*/
|
|
21
|
+
function extractPromptText(input) {
|
|
22
|
+
if (typeof input === 'string') return input;
|
|
23
|
+
if (!input) return '';
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(input)) {
|
|
26
|
+
return input.map(item => {
|
|
27
|
+
if (typeof item === 'string') return item;
|
|
28
|
+
if (item && typeof item === 'object') {
|
|
29
|
+
return item.content || item.text || item.message || JSON.stringify(item);
|
|
30
|
+
}
|
|
31
|
+
return String(item);
|
|
32
|
+
}).join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof input === 'object') {
|
|
36
|
+
if (input.content !== undefined) return extractPromptText(input.content);
|
|
37
|
+
if (input.text !== undefined) return extractPromptText(input.text);
|
|
38
|
+
if (input.message !== undefined) return extractPromptText(input.message);
|
|
39
|
+
if (input.prompt !== undefined) return extractPromptText(input.prompt);
|
|
40
|
+
if (input.input !== undefined) return extractPromptText(input.input);
|
|
41
|
+
if (Array.isArray(input.messages)) return extractPromptText(input.messages);
|
|
42
|
+
return JSON.stringify(input);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return String(input);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fast in-process prompt scan
|
|
50
|
+
* @param {string|Object|Array} prompt
|
|
51
|
+
* @param {Object} options
|
|
52
|
+
* @returns {Object} { label, confidence, safe, matches, scores }
|
|
53
|
+
*/
|
|
54
|
+
function scanPrompt(prompt, options = {}) {
|
|
55
|
+
const text = extractPromptText(prompt);
|
|
56
|
+
const threshold = options.threshold !== undefined ? options.threshold : 0.6;
|
|
57
|
+
|
|
58
|
+
if (!text || text.trim().length === 0) {
|
|
59
|
+
return { label: 'benign', confidence: 0, safe: true, matches: [], scores: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const variants = Normalizer.payloadVariants(text, options);
|
|
63
|
+
const signals = promptInjectionDetector.getSignals();
|
|
64
|
+
const matches = matchSignals(variants, signals, 'prompt-injection');
|
|
65
|
+
const confidence = combineConfidence(matches);
|
|
66
|
+
const isMalicious = confidence >= threshold;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
label: isMalicious ? 'prompt-injection' : 'benign',
|
|
70
|
+
confidence,
|
|
71
|
+
safe: !isMalicious,
|
|
72
|
+
matches,
|
|
73
|
+
scores: { 'prompt-injection': matches.length }
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional AI Judge integration for hybrid verification
|
|
79
|
+
*/
|
|
80
|
+
async function runAiJudge(promptText, aiJudgeConfig) {
|
|
81
|
+
if (typeof aiJudgeConfig === 'function') {
|
|
82
|
+
return await aiJudgeConfig(promptText);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (aiJudgeConfig && typeof aiJudgeConfig === 'object') {
|
|
86
|
+
const { provider, apiKey, endpoint, fallback = 'allow' } = aiJudgeConfig;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
if (provider === 'openai' && apiKey) {
|
|
90
|
+
// Use OpenAI Moderations API (built-in fetch)
|
|
91
|
+
const response = await fetch('https://api.openai.com/v1/moderations', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
'Authorization': `Bearer ${apiKey}`
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ input: promptText })
|
|
98
|
+
});
|
|
99
|
+
if (response.ok) {
|
|
100
|
+
const data = await response.json();
|
|
101
|
+
const flagged = data.results?.[0]?.flagged === true;
|
|
102
|
+
return { safe: !flagged, reason: flagged ? 'OpenAI moderation flag' : null };
|
|
103
|
+
}
|
|
104
|
+
} else if (provider === 'ollama') {
|
|
105
|
+
const url = endpoint || 'http://localhost:11434/api/generate';
|
|
106
|
+
const response = await fetch(url, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
model: aiJudgeConfig.model || 'llama-guard',
|
|
111
|
+
prompt: `Check this prompt for safety:\n${promptText}`,
|
|
112
|
+
stream: false
|
|
113
|
+
})
|
|
114
|
+
});
|
|
115
|
+
if (response.ok) {
|
|
116
|
+
const data = await response.json();
|
|
117
|
+
const isUnsafe = /unsafe/i.test(data.response || '');
|
|
118
|
+
return { safe: !isUnsafe, reason: isUnsafe ? 'Ollama safety flag' : null };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (fallback === 'block') {
|
|
123
|
+
return { safe: false, reason: `AI Judge failed: ${err.message}` };
|
|
124
|
+
}
|
|
125
|
+
return { safe: true, reason: 'AI Judge bypassed due to error' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { safe: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Asserts that a prompt is safe, throwing an error if injection/jailbreak is detected.
|
|
134
|
+
* @param {string|Object|Array} prompt
|
|
135
|
+
* @param {Object} options
|
|
136
|
+
* @returns {Object} result
|
|
137
|
+
*/
|
|
138
|
+
function assertSafePrompt(prompt, options = {}) {
|
|
139
|
+
const result = scanPrompt(prompt, options);
|
|
140
|
+
if (!result.safe) {
|
|
141
|
+
throw new FortifyPromptError(result, extractPromptText(prompt));
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Express / Connect / HTTP Middleware for LLM Endpoints
|
|
148
|
+
* @param {Object} options
|
|
149
|
+
* @returns {Function} middleware(req, res, next)
|
|
150
|
+
*/
|
|
151
|
+
function llmGuard(options = {}) {
|
|
152
|
+
const threshold = options.threshold !== undefined ? options.threshold : 0.6;
|
|
153
|
+
const fields = options.fields || ['prompt', 'message', 'messages', 'input', 'query', 'text'];
|
|
154
|
+
const dryRun = options.dryRun === true;
|
|
155
|
+
|
|
156
|
+
return async function llmGuardMiddleware(req, res, next) {
|
|
157
|
+
if (!req.body || typeof req.body !== 'object') {
|
|
158
|
+
return next();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let detectedThreat = null;
|
|
162
|
+
let checkedField = null;
|
|
163
|
+
let promptValue = null;
|
|
164
|
+
|
|
165
|
+
for (const field of fields) {
|
|
166
|
+
if (req.body[field] !== undefined) {
|
|
167
|
+
promptValue = req.body[field];
|
|
168
|
+
const result = scanPrompt(promptValue, { threshold, ...options });
|
|
169
|
+
|
|
170
|
+
if (!result.safe) {
|
|
171
|
+
detectedThreat = result;
|
|
172
|
+
checkedField = field;
|
|
173
|
+
break;
|
|
174
|
+
} else if (result.confidence >= 0.35 && options.aiJudge) {
|
|
175
|
+
// Gray zone: execute optional AI Judge
|
|
176
|
+
try {
|
|
177
|
+
const aiVerdict = await runAiJudge(extractPromptText(promptValue), options.aiJudge);
|
|
178
|
+
if (!aiVerdict.safe) {
|
|
179
|
+
detectedThreat = {
|
|
180
|
+
label: 'prompt-injection',
|
|
181
|
+
confidence: 0.95,
|
|
182
|
+
safe: false,
|
|
183
|
+
matches: [{ id: 'ai-judge-flagged', label: 'prompt-injection', confidence: 0.95 }],
|
|
184
|
+
reason: aiVerdict.reason
|
|
185
|
+
};
|
|
186
|
+
checkedField = field;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
} catch (_) {}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (detectedThreat) {
|
|
195
|
+
const event = {
|
|
196
|
+
type: 'fortifyjs.prompt_threat',
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
field: checkedField,
|
|
199
|
+
confidence: detectedThreat.confidence,
|
|
200
|
+
matches: detectedThreat.matches,
|
|
201
|
+
dryRun,
|
|
202
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
203
|
+
url: req.originalUrl || req.url
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
req.fortifyPromptThreat = event;
|
|
207
|
+
|
|
208
|
+
if (typeof options.onThreat === 'function') {
|
|
209
|
+
try { options.onThreat(event, req, res); } catch (_) {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!dryRun) {
|
|
213
|
+
if (typeof options.onBlocked === 'function') {
|
|
214
|
+
return options.onBlocked(req, res, event);
|
|
215
|
+
}
|
|
216
|
+
return res.status(403).json({
|
|
217
|
+
success: false,
|
|
218
|
+
error: 'Prompt security validation failed',
|
|
219
|
+
code: 'PROMPT_INJECTION_DETECTED',
|
|
220
|
+
field: checkedField
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
next();
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
scanPrompt,
|
|
231
|
+
assertSafePrompt,
|
|
232
|
+
llmGuard,
|
|
233
|
+
FortifyPromptError,
|
|
234
|
+
extractPromptText
|
|
235
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { MemoryStore } = require('./store');
|
|
3
|
+
|
|
2
4
|
class IPRateLimiter {
|
|
3
5
|
constructor(windowMs = 300000, maxCapacity = 10000, maxEventsPerKey = 1000) {
|
|
4
6
|
this.windowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : 300000;
|
|
@@ -41,30 +43,70 @@ class IPRateLimiter {
|
|
|
41
43
|
this.ips.set(ip, validTimestamps);
|
|
42
44
|
return validTimestamps.length;
|
|
43
45
|
}
|
|
44
|
-
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function rateLimiterFactory(options = {}) {
|
|
48
49
|
const windowMs = options.windowMs || 15 * 60 * 1000;
|
|
49
50
|
const max = options.max || 100;
|
|
50
|
-
const
|
|
51
|
+
const keyGenerator = options.keyGenerator || (req => req.ip || (req.connection && req.connection.remoteAddress) || (req.socket && req.socket.remoteAddress) || '127.0.0.1');
|
|
52
|
+
const standardHeaders = options.standardHeaders !== false;
|
|
53
|
+
const customStore = options.store;
|
|
54
|
+
const memoryStore = customStore || new MemoryStore({ cleanupIntervalMs: windowMs });
|
|
55
|
+
const localLimiter = new IPRateLimiter(windowMs, 10000, max + 1000);
|
|
51
56
|
|
|
52
|
-
return function rateLimitMiddleware(req, res, next) {
|
|
53
|
-
const
|
|
54
|
-
|
|
57
|
+
return async function rateLimitMiddleware(req, res, next) {
|
|
58
|
+
const key = keyGenerator(req);
|
|
59
|
+
|
|
60
|
+
if (customStore) {
|
|
61
|
+
try {
|
|
62
|
+
const { count, resetTime } = await customStore.increment(key, windowMs);
|
|
63
|
+
const remaining = Math.max(0, max - count);
|
|
64
|
+
|
|
65
|
+
if (standardHeaders && res.setHeader) {
|
|
66
|
+
res.setHeader('RateLimit-Limit', max);
|
|
67
|
+
res.setHeader('RateLimit-Remaining', remaining);
|
|
68
|
+
res.setHeader('RateLimit-Reset', Math.ceil(resetTime / 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (count > max) {
|
|
72
|
+
if (typeof options.handler === 'function') {
|
|
73
|
+
return options.handler(req, res, next, options);
|
|
74
|
+
}
|
|
75
|
+
if (res.status && res.json) {
|
|
76
|
+
return res.status(429).json({ error: 'Too many requests', retryAfter: Math.ceil((resetTime - Date.now()) / 1000) });
|
|
77
|
+
} else {
|
|
78
|
+
res.statusCode = 429;
|
|
79
|
+
return res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return next();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Fail open if store fails, or proceed with fallback
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Default synchronous in-memory tracking
|
|
89
|
+
const currentHits = localLimiter.recordSuspicious(key);
|
|
90
|
+
const remaining = Math.max(0, max - currentHits);
|
|
55
91
|
|
|
56
|
-
if (
|
|
92
|
+
if (standardHeaders && res.setHeader) {
|
|
93
|
+
res.setHeader('RateLimit-Limit', max);
|
|
94
|
+
res.setHeader('RateLimit-Remaining', remaining);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (currentHits > max) {
|
|
98
|
+
if (typeof options.handler === 'function') {
|
|
99
|
+
return options.handler(req, res, next, options);
|
|
100
|
+
}
|
|
57
101
|
if (res.status && res.json) {
|
|
58
|
-
res.status(429).json({ error: 'Too many requests' });
|
|
102
|
+
return res.status(429).json({ error: 'Too many requests' });
|
|
59
103
|
} else {
|
|
60
|
-
// Fallback for non-Express adapters if needed
|
|
61
104
|
res.statusCode = 429;
|
|
62
|
-
res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
105
|
+
return res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
63
106
|
}
|
|
64
|
-
return;
|
|
65
107
|
}
|
|
66
108
|
next();
|
|
67
109
|
};
|
|
68
110
|
}
|
|
69
111
|
|
|
70
|
-
module.exports = { IPRateLimiter, rateLimiterFactory };
|
|
112
|
+
module.exports = { IPRateLimiter, rateLimiterFactory, MemoryStore };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_STRIP_FIELDS = [
|
|
4
|
+
'isadmin',
|
|
5
|
+
'role',
|
|
6
|
+
'roles',
|
|
7
|
+
'permissions',
|
|
8
|
+
'isverified',
|
|
9
|
+
'verified',
|
|
10
|
+
'credit',
|
|
11
|
+
'balance',
|
|
12
|
+
'passwordhash',
|
|
13
|
+
'salt'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Strips sensitive/forbidden keys from an object recursively
|
|
18
|
+
* @param {Object} obj
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @returns {Object} { sanitized, strippedKeys, wasForbidden }
|
|
21
|
+
*/
|
|
22
|
+
function sanitizeObject(obj, options = {}) {
|
|
23
|
+
const stripList = (options.stripFields || DEFAULT_STRIP_FIELDS).map(f => String(f).toLowerCase());
|
|
24
|
+
const strippedKeys = [];
|
|
25
|
+
const visited = new WeakSet();
|
|
26
|
+
|
|
27
|
+
function clean(target) {
|
|
28
|
+
if (!target || typeof target !== 'object') return target;
|
|
29
|
+
if (visited.has(target)) return target;
|
|
30
|
+
visited.add(target);
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(target)) {
|
|
33
|
+
return target.map(item => clean(item));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const key of Object.keys(target)) {
|
|
38
|
+
const lowerKey = key.toLowerCase();
|
|
39
|
+
const isForbidden = stripList.some(field => {
|
|
40
|
+
if (field.startsWith('*') && field.endsWith('*')) {
|
|
41
|
+
return lowerKey.includes(field.slice(1, -1));
|
|
42
|
+
} else if (field.endsWith('*')) {
|
|
43
|
+
return lowerKey.startsWith(field.slice(0, -1));
|
|
44
|
+
}
|
|
45
|
+
return lowerKey === field;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (isForbidden) {
|
|
49
|
+
strippedKeys.push(key);
|
|
50
|
+
} else {
|
|
51
|
+
result[key] = clean(target[key]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sanitized = clean(obj);
|
|
58
|
+
return {
|
|
59
|
+
sanitized,
|
|
60
|
+
strippedKeys,
|
|
61
|
+
wasForbidden: strippedKeys.length > 0
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Express / Connect middleware for mass assignment sanitization
|
|
67
|
+
* @param {Object} options
|
|
68
|
+
* @returns {Function} middleware(req, res, next)
|
|
69
|
+
*/
|
|
70
|
+
function sanitizerFactory(options = {}) {
|
|
71
|
+
const rejectOnForbidden = options.rejectOnForbidden === true;
|
|
72
|
+
|
|
73
|
+
return function sanitizerMiddleware(req, res, next) {
|
|
74
|
+
if (req.body && typeof req.body === 'object') {
|
|
75
|
+
const { sanitized, strippedKeys, wasForbidden } = sanitizeObject(req.body, options);
|
|
76
|
+
if (wasForbidden) {
|
|
77
|
+
if (rejectOnForbidden) {
|
|
78
|
+
return res.status(400).json({
|
|
79
|
+
success: false,
|
|
80
|
+
error: 'Forbidden parameter in request body',
|
|
81
|
+
code: 'MASS_ASSIGNMENT_VIOLATION',
|
|
82
|
+
fields: strippedKeys
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
req.body = sanitized;
|
|
86
|
+
req.fortifyStrippedKeys = strippedKeys;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (req.query && typeof req.query === 'object') {
|
|
91
|
+
const { sanitized, strippedKeys, wasForbidden } = sanitizeObject(req.query, options);
|
|
92
|
+
if (wasForbidden) {
|
|
93
|
+
if (rejectOnForbidden) {
|
|
94
|
+
return res.status(400).json({
|
|
95
|
+
success: false,
|
|
96
|
+
error: 'Forbidden parameter in request query',
|
|
97
|
+
code: 'MASS_ASSIGNMENT_VIOLATION',
|
|
98
|
+
fields: strippedKeys
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
req.query = sanitized;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
next();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
sanitizerFactory,
|
|
111
|
+
sanitizeObject,
|
|
112
|
+
DEFAULT_STRIP_FIELDS
|
|
113
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base Store contract for distributed rate limiting & state storage.
|
|
5
|
+
*/
|
|
6
|
+
class BaseStore {
|
|
7
|
+
async get(key) { throw new Error('Not implemented'); }
|
|
8
|
+
async set(key, value, ttlMs) { throw new Error('Not implemented'); }
|
|
9
|
+
async increment(key, ttlMs) { throw new Error('Not implemented'); }
|
|
10
|
+
async delete(key) { throw new Error('Not implemented'); }
|
|
11
|
+
async clear() { throw new Error('Not implemented'); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* High-performance, zero-dependency in-memory store with TTL and bounded capacity.
|
|
16
|
+
*/
|
|
17
|
+
class MemoryStore extends BaseStore {
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.maxEntries = options.maxEntries || 10000;
|
|
21
|
+
this.entries = new Map();
|
|
22
|
+
this.cleanupIntervalMs = options.cleanupIntervalMs || 60000;
|
|
23
|
+
|
|
24
|
+
// Background garbage collection
|
|
25
|
+
this.timer = setInterval(() => this.purgeExpired(), this.cleanupIntervalMs);
|
|
26
|
+
if (this.timer.unref) this.timer.unref();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
purgeExpired() {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [key, record] of this.entries.entries()) {
|
|
32
|
+
if (record.expiresAt && record.expiresAt <= now) {
|
|
33
|
+
this.entries.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(key) {
|
|
39
|
+
const record = this.entries.get(key);
|
|
40
|
+
if (!record) return null;
|
|
41
|
+
if (record.expiresAt && record.expiresAt <= Date.now()) {
|
|
42
|
+
this.entries.delete(key);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return record.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async set(key, value, ttlMs = 60000) {
|
|
49
|
+
// If at capacity, evict oldest entry
|
|
50
|
+
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
|
|
51
|
+
const firstKey = this.entries.keys().next().value;
|
|
52
|
+
if (firstKey !== undefined) this.entries.delete(firstKey);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
|
56
|
+
this.entries.set(key, { value, expiresAt });
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async increment(key, ttlMs = 60000) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
let record = this.entries.get(key);
|
|
63
|
+
|
|
64
|
+
if (!record || (record.expiresAt && record.expiresAt <= now)) {
|
|
65
|
+
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
|
|
66
|
+
const firstKey = this.entries.keys().next().value;
|
|
67
|
+
if (firstKey !== undefined) this.entries.delete(firstKey);
|
|
68
|
+
}
|
|
69
|
+
record = { value: 1, expiresAt: now + ttlMs };
|
|
70
|
+
this.entries.set(key, record);
|
|
71
|
+
return { count: 1, resetTime: record.expiresAt };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
record.value += 1;
|
|
75
|
+
return { count: record.value, resetTime: record.expiresAt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async delete(key) {
|
|
79
|
+
return this.entries.delete(key);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async clear() {
|
|
83
|
+
this.entries.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
destroy() {
|
|
87
|
+
if (this.timer) {
|
|
88
|
+
clearInterval(this.timer);
|
|
89
|
+
}
|
|
90
|
+
this.entries.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
BaseStore,
|
|
96
|
+
MemoryStore
|
|
97
|
+
};
|