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.
@@ -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
+ };