mastercontroller 1.3.10 → 1.3.13
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/.claude/settings.local.json +4 -1
- package/.eslintrc.json +50 -0
- package/.github/workflows/ci.yml +317 -0
- package/.prettierrc +10 -0
- package/DEPLOYMENT.md +956 -0
- package/MasterControl.js +98 -16
- package/MasterRequest.js +42 -1
- package/MasterRouter.js +15 -5
- package/README.md +485 -28
- package/SENIOR_ENGINEER_AUDIT.md +2477 -0
- package/VERIFICATION_CHECKLIST.md +726 -0
- package/error/README.md +2452 -0
- package/monitoring/HealthCheck.js +347 -0
- package/monitoring/PrometheusExporter.js +416 -0
- package/package.json +64 -11
- package/security/MasterValidator.js +140 -10
- package/security/adapters/RedisCSRFStore.js +428 -0
- package/security/adapters/RedisRateLimiter.js +462 -0
- package/security/adapters/RedisSessionStore.js +476 -0
- package/FIXES_APPLIED.md +0 -378
- package/error/ErrorBoundary.js +0 -353
- package/error/HydrationMismatch.js +0 -265
- package/error/MasterError.js +0 -240
- package/error/MasterError.js.tmp +0 -0
- package/error/MasterErrorRenderer.js +0 -536
- package/error/MasterErrorRenderer.js.tmp +0 -0
- package/error/SSRErrorHandler.js +0 -273
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedisSessionStore - Redis-based session storage for horizontal scaling
|
|
3
|
+
* Version: 1.0.0
|
|
4
|
+
*
|
|
5
|
+
* Enables distributed session management across multiple MasterController instances.
|
|
6
|
+
* Essential for load-balanced, horizontally scaled Fortune 500 deployments.
|
|
7
|
+
*
|
|
8
|
+
* Installation:
|
|
9
|
+
* npm install ioredis --save
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* const Redis = require('ioredis');
|
|
14
|
+
* const { RedisSessionStore } = require('./security/adapters/RedisSessionStore');
|
|
15
|
+
*
|
|
16
|
+
* const redis = new Redis({
|
|
17
|
+
* host: 'localhost',
|
|
18
|
+
* port: 6379,
|
|
19
|
+
* // For production clusters:
|
|
20
|
+
* // cluster: [{ host: 'node1', port: 6379 }, { host: 'node2', port: 6379 }]
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const sessionStore = new RedisSessionStore(redis, {
|
|
24
|
+
* prefix: 'sess:',
|
|
25
|
+
* ttl: 86400 // 24 hours
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // Replace default session storage
|
|
29
|
+
* master.session.setStore(sessionStore);
|
|
30
|
+
*
|
|
31
|
+
* Features:
|
|
32
|
+
* - Session sharing across multiple app instances
|
|
33
|
+
* - Automatic TTL and expiration
|
|
34
|
+
* - Session locking for race condition prevention
|
|
35
|
+
* - Graceful degradation if Redis unavailable
|
|
36
|
+
* - Connection pooling and retry logic
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const { logger } = require('../../error/MasterErrorLogger');
|
|
40
|
+
|
|
41
|
+
class RedisSessionStore {
|
|
42
|
+
constructor(redisClient, options = {}) {
|
|
43
|
+
if (!redisClient) {
|
|
44
|
+
throw new Error('RedisSessionStore requires a Redis client (ioredis)');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.redis = redisClient;
|
|
48
|
+
this.options = {
|
|
49
|
+
prefix: options.prefix || 'mastercontroller:session:',
|
|
50
|
+
ttl: options.ttl || 86400, // 24 hours default
|
|
51
|
+
scanCount: options.scanCount || 100,
|
|
52
|
+
enableLocking: options.enableLocking !== false, // Session locking enabled by default
|
|
53
|
+
lockTimeout: options.lockTimeout || 10000, // 10 seconds
|
|
54
|
+
serializer: options.serializer || JSON,
|
|
55
|
+
...options
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Track connection status
|
|
59
|
+
this.connected = false;
|
|
60
|
+
|
|
61
|
+
// Setup Redis event handlers
|
|
62
|
+
this._setupEventHandlers();
|
|
63
|
+
|
|
64
|
+
logger.info({
|
|
65
|
+
code: 'MC_SESSION_REDIS_INIT',
|
|
66
|
+
message: 'Redis session store initialized',
|
|
67
|
+
prefix: this.options.prefix,
|
|
68
|
+
ttl: this.options.ttl
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Setup Redis event handlers
|
|
74
|
+
*/
|
|
75
|
+
_setupEventHandlers() {
|
|
76
|
+
this.redis.on('connect', () => {
|
|
77
|
+
this.connected = true;
|
|
78
|
+
logger.info({
|
|
79
|
+
code: 'MC_SESSION_REDIS_CONNECTED',
|
|
80
|
+
message: 'Redis session store connected'
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.redis.on('error', (err) => {
|
|
85
|
+
logger.error({
|
|
86
|
+
code: 'MC_SESSION_REDIS_ERROR',
|
|
87
|
+
message: 'Redis session store error',
|
|
88
|
+
error: err.message
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.redis.on('close', () => {
|
|
93
|
+
this.connected = false;
|
|
94
|
+
logger.warn({
|
|
95
|
+
code: 'MC_SESSION_REDIS_DISCONNECTED',
|
|
96
|
+
message: 'Redis session store disconnected'
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate Redis key for session ID
|
|
103
|
+
*/
|
|
104
|
+
_getKey(sessionId) {
|
|
105
|
+
return `${this.options.prefix}${sessionId}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate lock key for session ID
|
|
110
|
+
*/
|
|
111
|
+
_getLockKey(sessionId) {
|
|
112
|
+
return `${this.options.prefix}lock:${sessionId}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get session data
|
|
117
|
+
*/
|
|
118
|
+
async get(sessionId) {
|
|
119
|
+
try {
|
|
120
|
+
const key = this._getKey(sessionId);
|
|
121
|
+
const data = await this.redis.get(key);
|
|
122
|
+
|
|
123
|
+
if (!data) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Deserialize session data
|
|
128
|
+
const session = this.options.serializer.parse(data);
|
|
129
|
+
|
|
130
|
+
logger.debug({
|
|
131
|
+
code: 'MC_SESSION_REDIS_GET',
|
|
132
|
+
message: 'Session retrieved from Redis',
|
|
133
|
+
sessionId: sessionId
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return session;
|
|
137
|
+
|
|
138
|
+
} catch (error) {
|
|
139
|
+
logger.error({
|
|
140
|
+
code: 'MC_SESSION_REDIS_GET_ERROR',
|
|
141
|
+
message: 'Failed to get session from Redis',
|
|
142
|
+
sessionId: sessionId,
|
|
143
|
+
error: error.message
|
|
144
|
+
});
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set session data with TTL
|
|
151
|
+
*/
|
|
152
|
+
async set(sessionId, sessionData, ttl = null) {
|
|
153
|
+
try {
|
|
154
|
+
const key = this._getKey(sessionId);
|
|
155
|
+
const expiry = ttl || this.options.ttl;
|
|
156
|
+
|
|
157
|
+
// Serialize session data
|
|
158
|
+
const serialized = this.options.serializer.stringify(sessionData);
|
|
159
|
+
|
|
160
|
+
// Set with expiry
|
|
161
|
+
await this.redis.setex(key, expiry, serialized);
|
|
162
|
+
|
|
163
|
+
logger.debug({
|
|
164
|
+
code: 'MC_SESSION_REDIS_SET',
|
|
165
|
+
message: 'Session saved to Redis',
|
|
166
|
+
sessionId: sessionId,
|
|
167
|
+
ttl: expiry
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return true;
|
|
171
|
+
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error({
|
|
174
|
+
code: 'MC_SESSION_REDIS_SET_ERROR',
|
|
175
|
+
message: 'Failed to set session in Redis',
|
|
176
|
+
sessionId: sessionId,
|
|
177
|
+
error: error.message
|
|
178
|
+
});
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update session data and refresh TTL
|
|
185
|
+
*/
|
|
186
|
+
async update(sessionId, sessionData, ttl = null) {
|
|
187
|
+
return await this.set(sessionId, sessionData, ttl);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Delete session
|
|
192
|
+
*/
|
|
193
|
+
async destroy(sessionId) {
|
|
194
|
+
try {
|
|
195
|
+
const key = this._getKey(sessionId);
|
|
196
|
+
await this.redis.del(key);
|
|
197
|
+
|
|
198
|
+
// Also delete lock if exists
|
|
199
|
+
if (this.options.enableLocking) {
|
|
200
|
+
const lockKey = this._getLockKey(sessionId);
|
|
201
|
+
await this.redis.del(lockKey);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
logger.debug({
|
|
205
|
+
code: 'MC_SESSION_REDIS_DESTROY',
|
|
206
|
+
message: 'Session destroyed',
|
|
207
|
+
sessionId: sessionId
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return true;
|
|
211
|
+
|
|
212
|
+
} catch (error) {
|
|
213
|
+
logger.error({
|
|
214
|
+
code: 'MC_SESSION_REDIS_DESTROY_ERROR',
|
|
215
|
+
message: 'Failed to destroy session',
|
|
216
|
+
sessionId: sessionId,
|
|
217
|
+
error: error.message
|
|
218
|
+
});
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Refresh session TTL without modifying data
|
|
225
|
+
*/
|
|
226
|
+
async touch(sessionId, ttl = null) {
|
|
227
|
+
try {
|
|
228
|
+
const key = this._getKey(sessionId);
|
|
229
|
+
const expiry = ttl || this.options.ttl;
|
|
230
|
+
|
|
231
|
+
await this.redis.expire(key, expiry);
|
|
232
|
+
|
|
233
|
+
logger.debug({
|
|
234
|
+
code: 'MC_SESSION_REDIS_TOUCH',
|
|
235
|
+
message: 'Session TTL refreshed',
|
|
236
|
+
sessionId: sessionId,
|
|
237
|
+
ttl: expiry
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return true;
|
|
241
|
+
|
|
242
|
+
} catch (error) {
|
|
243
|
+
logger.error({
|
|
244
|
+
code: 'MC_SESSION_REDIS_TOUCH_ERROR',
|
|
245
|
+
message: 'Failed to touch session',
|
|
246
|
+
sessionId: sessionId,
|
|
247
|
+
error: error.message
|
|
248
|
+
});
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Acquire lock on session (prevents race conditions)
|
|
255
|
+
*/
|
|
256
|
+
async acquireLock(sessionId, timeout = null) {
|
|
257
|
+
if (!this.options.enableLocking) {
|
|
258
|
+
return true; // Locking disabled
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const lockKey = this._getLockKey(sessionId);
|
|
263
|
+
const lockTimeout = timeout || this.options.lockTimeout;
|
|
264
|
+
const lockValue = `${Date.now()}-${Math.random()}`; // Unique lock value
|
|
265
|
+
|
|
266
|
+
// Try to set lock with NX (only if not exists)
|
|
267
|
+
const acquired = await this.redis.set(
|
|
268
|
+
lockKey,
|
|
269
|
+
lockValue,
|
|
270
|
+
'PX', // Milliseconds
|
|
271
|
+
lockTimeout,
|
|
272
|
+
'NX' // Only set if not exists
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
if (acquired === 'OK') {
|
|
276
|
+
logger.debug({
|
|
277
|
+
code: 'MC_SESSION_LOCK_ACQUIRED',
|
|
278
|
+
message: 'Session lock acquired',
|
|
279
|
+
sessionId: sessionId
|
|
280
|
+
});
|
|
281
|
+
return lockValue; // Return lock value for release
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
logger.warn({
|
|
285
|
+
code: 'MC_SESSION_LOCK_FAILED',
|
|
286
|
+
message: 'Failed to acquire session lock',
|
|
287
|
+
sessionId: sessionId
|
|
288
|
+
});
|
|
289
|
+
return null;
|
|
290
|
+
|
|
291
|
+
} catch (error) {
|
|
292
|
+
logger.error({
|
|
293
|
+
code: 'MC_SESSION_LOCK_ERROR',
|
|
294
|
+
message: 'Error acquiring session lock',
|
|
295
|
+
sessionId: sessionId,
|
|
296
|
+
error: error.message
|
|
297
|
+
});
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Release lock on session
|
|
304
|
+
*/
|
|
305
|
+
async releaseLock(sessionId, lockValue) {
|
|
306
|
+
if (!this.options.enableLocking || !lockValue) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const lockKey = this._getLockKey(sessionId);
|
|
312
|
+
|
|
313
|
+
// Use Lua script to ensure we only delete our own lock
|
|
314
|
+
const script = `
|
|
315
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
316
|
+
return redis.call("del", KEYS[1])
|
|
317
|
+
else
|
|
318
|
+
return 0
|
|
319
|
+
end
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
const result = await this.redis.eval(script, 1, lockKey, lockValue);
|
|
323
|
+
|
|
324
|
+
if (result === 1) {
|
|
325
|
+
logger.debug({
|
|
326
|
+
code: 'MC_SESSION_LOCK_RELEASED',
|
|
327
|
+
message: 'Session lock released',
|
|
328
|
+
sessionId: sessionId
|
|
329
|
+
});
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
logger.warn({
|
|
334
|
+
code: 'MC_SESSION_LOCK_RELEASE_FAILED',
|
|
335
|
+
message: 'Lock value mismatch or already released',
|
|
336
|
+
sessionId: sessionId
|
|
337
|
+
});
|
|
338
|
+
return false;
|
|
339
|
+
|
|
340
|
+
} catch (error) {
|
|
341
|
+
logger.error({
|
|
342
|
+
code: 'MC_SESSION_LOCK_RELEASE_ERROR',
|
|
343
|
+
message: 'Error releasing session lock',
|
|
344
|
+
sessionId: sessionId,
|
|
345
|
+
error: error.message
|
|
346
|
+
});
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get all session IDs (for admin/debugging)
|
|
353
|
+
* WARNING: Can be slow on large datasets - use with caution
|
|
354
|
+
*/
|
|
355
|
+
async getAllSessions() {
|
|
356
|
+
try {
|
|
357
|
+
const sessions = [];
|
|
358
|
+
const pattern = `${this.options.prefix}*`;
|
|
359
|
+
|
|
360
|
+
// Use SCAN for non-blocking iteration
|
|
361
|
+
let cursor = '0';
|
|
362
|
+
do {
|
|
363
|
+
const [newCursor, keys] = await this.redis.scan(
|
|
364
|
+
cursor,
|
|
365
|
+
'MATCH',
|
|
366
|
+
pattern,
|
|
367
|
+
'COUNT',
|
|
368
|
+
this.options.scanCount
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
cursor = newCursor;
|
|
372
|
+
|
|
373
|
+
// Filter out lock keys
|
|
374
|
+
const sessionKeys = keys.filter(k => !k.includes(':lock:'));
|
|
375
|
+
|
|
376
|
+
for (const key of sessionKeys) {
|
|
377
|
+
const sessionId = key.replace(this.options.prefix, '');
|
|
378
|
+
sessions.push(sessionId);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
} while (cursor !== '0');
|
|
382
|
+
|
|
383
|
+
logger.debug({
|
|
384
|
+
code: 'MC_SESSION_REDIS_GET_ALL',
|
|
385
|
+
message: 'Retrieved all session IDs',
|
|
386
|
+
count: sessions.length
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return sessions;
|
|
390
|
+
|
|
391
|
+
} catch (error) {
|
|
392
|
+
logger.error({
|
|
393
|
+
code: 'MC_SESSION_REDIS_GET_ALL_ERROR',
|
|
394
|
+
message: 'Failed to get all sessions',
|
|
395
|
+
error: error.message
|
|
396
|
+
});
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get session count
|
|
403
|
+
*/
|
|
404
|
+
async getSessionCount() {
|
|
405
|
+
try {
|
|
406
|
+
const sessions = await this.getAllSessions();
|
|
407
|
+
return sessions.length;
|
|
408
|
+
} catch (error) {
|
|
409
|
+
logger.error({
|
|
410
|
+
code: 'MC_SESSION_REDIS_COUNT_ERROR',
|
|
411
|
+
message: 'Failed to count sessions',
|
|
412
|
+
error: error.message
|
|
413
|
+
});
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Clear all sessions (for testing/maintenance)
|
|
420
|
+
*/
|
|
421
|
+
async clearAll() {
|
|
422
|
+
try {
|
|
423
|
+
const sessions = await this.getAllSessions();
|
|
424
|
+
|
|
425
|
+
for (const sessionId of sessions) {
|
|
426
|
+
await this.destroy(sessionId);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
logger.info({
|
|
430
|
+
code: 'MC_SESSION_REDIS_CLEAR_ALL',
|
|
431
|
+
message: 'All sessions cleared',
|
|
432
|
+
count: sessions.length
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return sessions.length;
|
|
436
|
+
|
|
437
|
+
} catch (error) {
|
|
438
|
+
logger.error({
|
|
439
|
+
code: 'MC_SESSION_REDIS_CLEAR_ALL_ERROR',
|
|
440
|
+
message: 'Failed to clear all sessions',
|
|
441
|
+
error: error.message
|
|
442
|
+
});
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Check if store is connected and healthy
|
|
449
|
+
*/
|
|
450
|
+
isHealthy() {
|
|
451
|
+
return this.connected;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Close Redis connection
|
|
456
|
+
*/
|
|
457
|
+
async close() {
|
|
458
|
+
try {
|
|
459
|
+
await this.redis.quit();
|
|
460
|
+
logger.info({
|
|
461
|
+
code: 'MC_SESSION_REDIS_CLOSED',
|
|
462
|
+
message: 'Redis session store closed'
|
|
463
|
+
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
logger.error({
|
|
466
|
+
code: 'MC_SESSION_REDIS_CLOSE_ERROR',
|
|
467
|
+
message: 'Error closing Redis connection',
|
|
468
|
+
error: error.message
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
module.exports = {
|
|
475
|
+
RedisSessionStore
|
|
476
|
+
};
|