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,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedisRateLimiter - Distributed rate limiting for horizontal scaling
|
|
3
|
+
* Version: 1.0.0
|
|
4
|
+
*
|
|
5
|
+
* Implements rate limiting across multiple MasterController instances using Redis.
|
|
6
|
+
* Essential for Fortune 500 load-balanced deployments to prevent API abuse.
|
|
7
|
+
*
|
|
8
|
+
* Installation:
|
|
9
|
+
* npm install ioredis --save
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* const Redis = require('ioredis');
|
|
14
|
+
* const { RedisRateLimiter } = require('./security/adapters/RedisRateLimiter');
|
|
15
|
+
*
|
|
16
|
+
* const redis = new Redis({ host: 'localhost', port: 6379 });
|
|
17
|
+
*
|
|
18
|
+
* const rateLimiter = new RedisRateLimiter(redis, {
|
|
19
|
+
* points: 100, // Number of requests
|
|
20
|
+
* duration: 60, // Per 60 seconds
|
|
21
|
+
* blockDuration: 300 // Block for 5 minutes on exceed
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* // In MasterPipeline middleware:
|
|
25
|
+
* const allowed = await rateLimiter.consume(ctx.request.connection.remoteAddress);
|
|
26
|
+
* if (!allowed) {
|
|
27
|
+
* ctx.response.statusCode = 429;
|
|
28
|
+
* ctx.response.end('Too Many Requests');
|
|
29
|
+
* return;
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* Features:
|
|
33
|
+
* - Token bucket algorithm with Redis
|
|
34
|
+
* - Distributed rate limiting across instances
|
|
35
|
+
* - Per-IP, per-user, or custom key limiting
|
|
36
|
+
* - Automatic cleanup of expired keys
|
|
37
|
+
* - Configurable block duration on limit exceed
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const { logger } = require('../../error/MasterErrorLogger');
|
|
41
|
+
|
|
42
|
+
class RedisRateLimiter {
|
|
43
|
+
constructor(redisClient, options = {}) {
|
|
44
|
+
if (!redisClient) {
|
|
45
|
+
throw new Error('RedisRateLimiter requires a Redis client (ioredis)');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.redis = redisClient;
|
|
49
|
+
this.options = {
|
|
50
|
+
prefix: options.prefix || 'mastercontroller:ratelimit:',
|
|
51
|
+
points: options.points || 100, // Max requests
|
|
52
|
+
duration: options.duration || 60, // Time window in seconds
|
|
53
|
+
blockDuration: options.blockDuration || 0, // Block duration on exceed (0 = no block)
|
|
54
|
+
execEvenly: options.execEvenly || false, // Spread requests evenly over duration
|
|
55
|
+
...options
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
logger.info({
|
|
59
|
+
code: 'MC_RATELIMIT_REDIS_INIT',
|
|
60
|
+
message: 'Redis rate limiter initialized',
|
|
61
|
+
points: this.options.points,
|
|
62
|
+
duration: this.options.duration
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate Redis key for rate limit
|
|
68
|
+
*/
|
|
69
|
+
_getKey(identifier) {
|
|
70
|
+
return `${this.options.prefix}${identifier}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate block key
|
|
75
|
+
*/
|
|
76
|
+
_getBlockKey(identifier) {
|
|
77
|
+
return `${this.options.prefix}block:${identifier}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Consume points (check if request is allowed)
|
|
82
|
+
* Returns object with: { allowed, remaining, resetAt }
|
|
83
|
+
*/
|
|
84
|
+
async consume(identifier, points = 1) {
|
|
85
|
+
try {
|
|
86
|
+
const key = this._getKey(identifier);
|
|
87
|
+
const blockKey = this._getBlockKey(identifier);
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
|
|
90
|
+
// Check if identifier is blocked
|
|
91
|
+
const blockExpiry = await this.redis.get(blockKey);
|
|
92
|
+
if (blockExpiry && parseInt(blockExpiry) > now) {
|
|
93
|
+
logger.debug({
|
|
94
|
+
code: 'MC_RATELIMIT_BLOCKED',
|
|
95
|
+
message: 'Request blocked due to rate limit',
|
|
96
|
+
identifier: identifier,
|
|
97
|
+
blockedUntil: new Date(parseInt(blockExpiry)).toISOString()
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
allowed: false,
|
|
102
|
+
remaining: 0,
|
|
103
|
+
resetAt: parseInt(blockExpiry),
|
|
104
|
+
blocked: true
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Use Lua script for atomic rate limiting
|
|
109
|
+
const script = `
|
|
110
|
+
local key = KEYS[1]
|
|
111
|
+
local points = tonumber(ARGV[1])
|
|
112
|
+
local duration = tonumber(ARGV[2])
|
|
113
|
+
local max_points = tonumber(ARGV[3])
|
|
114
|
+
local now = tonumber(ARGV[4])
|
|
115
|
+
|
|
116
|
+
-- Get current counter
|
|
117
|
+
local current = redis.call('GET', key)
|
|
118
|
+
local ttl = redis.call('TTL', key)
|
|
119
|
+
|
|
120
|
+
if current == false then
|
|
121
|
+
-- First request, initialize counter
|
|
122
|
+
redis.call('SETEX', key, duration, points)
|
|
123
|
+
return {max_points - points, now + (duration * 1000)}
|
|
124
|
+
else
|
|
125
|
+
current = tonumber(current)
|
|
126
|
+
|
|
127
|
+
if current + points <= max_points then
|
|
128
|
+
-- Allow request
|
|
129
|
+
redis.call('INCRBY', key, points)
|
|
130
|
+
local remaining = max_points - (current + points)
|
|
131
|
+
local reset_at = now + (ttl * 1000)
|
|
132
|
+
return {remaining, reset_at}
|
|
133
|
+
else
|
|
134
|
+
-- Deny request (over limit)
|
|
135
|
+
local reset_at = now + (ttl * 1000)
|
|
136
|
+
return {0, reset_at}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
const result = await this.redis.eval(
|
|
142
|
+
script,
|
|
143
|
+
1,
|
|
144
|
+
key,
|
|
145
|
+
points,
|
|
146
|
+
this.options.duration,
|
|
147
|
+
this.options.points,
|
|
148
|
+
now
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const remaining = result[0];
|
|
152
|
+
const resetAt = result[1];
|
|
153
|
+
const allowed = remaining >= 0;
|
|
154
|
+
|
|
155
|
+
if (!allowed) {
|
|
156
|
+
// Over limit - create block if configured
|
|
157
|
+
if (this.options.blockDuration > 0) {
|
|
158
|
+
const blockUntil = now + (this.options.blockDuration * 1000);
|
|
159
|
+
await this.redis.setex(
|
|
160
|
+
blockKey,
|
|
161
|
+
this.options.blockDuration,
|
|
162
|
+
blockUntil.toString()
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
logger.warn({
|
|
166
|
+
code: 'MC_RATELIMIT_EXCEEDED_BLOCKED',
|
|
167
|
+
message: 'Rate limit exceeded, identifier blocked',
|
|
168
|
+
identifier: identifier,
|
|
169
|
+
blockDuration: this.options.blockDuration,
|
|
170
|
+
blockedUntil: new Date(blockUntil).toISOString()
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
logger.warn({
|
|
174
|
+
code: 'MC_RATELIMIT_EXCEEDED',
|
|
175
|
+
message: 'Rate limit exceeded',
|
|
176
|
+
identifier: identifier
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
allowed: allowed,
|
|
183
|
+
remaining: Math.max(0, remaining),
|
|
184
|
+
resetAt: resetAt,
|
|
185
|
+
blocked: false
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger.error({
|
|
190
|
+
code: 'MC_RATELIMIT_ERROR',
|
|
191
|
+
message: 'Rate limit check failed',
|
|
192
|
+
identifier: identifier,
|
|
193
|
+
error: error.message
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// On error, allow request (fail open for availability)
|
|
197
|
+
return {
|
|
198
|
+
allowed: true,
|
|
199
|
+
remaining: this.options.points,
|
|
200
|
+
resetAt: Date.now() + (this.options.duration * 1000),
|
|
201
|
+
error: true
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Consume multiple points at once
|
|
208
|
+
*/
|
|
209
|
+
async consumePoints(identifier, points) {
|
|
210
|
+
return await this.consume(identifier, points);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get current rate limit status without consuming
|
|
215
|
+
*/
|
|
216
|
+
async get(identifier) {
|
|
217
|
+
try {
|
|
218
|
+
const key = this._getKey(identifier);
|
|
219
|
+
const blockKey = this._getBlockKey(identifier);
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
|
|
222
|
+
// Check if blocked
|
|
223
|
+
const blockExpiry = await this.redis.get(blockKey);
|
|
224
|
+
if (blockExpiry && parseInt(blockExpiry) > now) {
|
|
225
|
+
return {
|
|
226
|
+
consumed: this.options.points,
|
|
227
|
+
remaining: 0,
|
|
228
|
+
resetAt: parseInt(blockExpiry),
|
|
229
|
+
blocked: true
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Get current consumption
|
|
234
|
+
const consumed = await this.redis.get(key);
|
|
235
|
+
const ttl = await this.redis.ttl(key);
|
|
236
|
+
|
|
237
|
+
if (!consumed) {
|
|
238
|
+
return {
|
|
239
|
+
consumed: 0,
|
|
240
|
+
remaining: this.options.points,
|
|
241
|
+
resetAt: now + (this.options.duration * 1000),
|
|
242
|
+
blocked: false
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const remaining = Math.max(0, this.options.points - parseInt(consumed));
|
|
247
|
+
const resetAt = now + (ttl * 1000);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
consumed: parseInt(consumed),
|
|
251
|
+
remaining: remaining,
|
|
252
|
+
resetAt: resetAt,
|
|
253
|
+
blocked: false
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
} catch (error) {
|
|
257
|
+
logger.error({
|
|
258
|
+
code: 'MC_RATELIMIT_GET_ERROR',
|
|
259
|
+
message: 'Failed to get rate limit status',
|
|
260
|
+
identifier: identifier,
|
|
261
|
+
error: error.message
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
consumed: 0,
|
|
266
|
+
remaining: this.options.points,
|
|
267
|
+
resetAt: Date.now() + (this.options.duration * 1000),
|
|
268
|
+
error: true
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Reset rate limit for identifier
|
|
275
|
+
*/
|
|
276
|
+
async reset(identifier) {
|
|
277
|
+
try {
|
|
278
|
+
const key = this._getKey(identifier);
|
|
279
|
+
const blockKey = this._getBlockKey(identifier);
|
|
280
|
+
|
|
281
|
+
await this.redis.del(key);
|
|
282
|
+
await this.redis.del(blockKey);
|
|
283
|
+
|
|
284
|
+
logger.debug({
|
|
285
|
+
code: 'MC_RATELIMIT_RESET',
|
|
286
|
+
message: 'Rate limit reset',
|
|
287
|
+
identifier: identifier
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return true;
|
|
291
|
+
|
|
292
|
+
} catch (error) {
|
|
293
|
+
logger.error({
|
|
294
|
+
code: 'MC_RATELIMIT_RESET_ERROR',
|
|
295
|
+
message: 'Failed to reset rate limit',
|
|
296
|
+
identifier: identifier,
|
|
297
|
+
error: error.message
|
|
298
|
+
});
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Block identifier for specified duration (seconds)
|
|
305
|
+
*/
|
|
306
|
+
async block(identifier, duration = null) {
|
|
307
|
+
try {
|
|
308
|
+
const blockKey = this._getBlockKey(identifier);
|
|
309
|
+
const blockDuration = duration || this.options.blockDuration;
|
|
310
|
+
const blockUntil = Date.now() + (blockDuration * 1000);
|
|
311
|
+
|
|
312
|
+
await this.redis.setex(
|
|
313
|
+
blockKey,
|
|
314
|
+
blockDuration,
|
|
315
|
+
blockUntil.toString()
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
logger.info({
|
|
319
|
+
code: 'MC_RATELIMIT_MANUAL_BLOCK',
|
|
320
|
+
message: 'Identifier manually blocked',
|
|
321
|
+
identifier: identifier,
|
|
322
|
+
duration: blockDuration,
|
|
323
|
+
blockedUntil: new Date(blockUntil).toISOString()
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return true;
|
|
327
|
+
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.error({
|
|
330
|
+
code: 'MC_RATELIMIT_BLOCK_ERROR',
|
|
331
|
+
message: 'Failed to block identifier',
|
|
332
|
+
identifier: identifier,
|
|
333
|
+
error: error.message
|
|
334
|
+
});
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Unblock identifier
|
|
341
|
+
*/
|
|
342
|
+
async unblock(identifier) {
|
|
343
|
+
try {
|
|
344
|
+
const blockKey = this._getBlockKey(identifier);
|
|
345
|
+
await this.redis.del(blockKey);
|
|
346
|
+
|
|
347
|
+
logger.info({
|
|
348
|
+
code: 'MC_RATELIMIT_UNBLOCK',
|
|
349
|
+
message: 'Identifier unblocked',
|
|
350
|
+
identifier: identifier
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return true;
|
|
354
|
+
|
|
355
|
+
} catch (error) {
|
|
356
|
+
logger.error({
|
|
357
|
+
code: 'MC_RATELIMIT_UNBLOCK_ERROR',
|
|
358
|
+
message: 'Failed to unblock identifier',
|
|
359
|
+
identifier: identifier,
|
|
360
|
+
error: error.message
|
|
361
|
+
});
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check if identifier is blocked
|
|
368
|
+
*/
|
|
369
|
+
async isBlocked(identifier) {
|
|
370
|
+
try {
|
|
371
|
+
const blockKey = this._getBlockKey(identifier);
|
|
372
|
+
const blockExpiry = await this.redis.get(blockKey);
|
|
373
|
+
|
|
374
|
+
if (blockExpiry && parseInt(blockExpiry) > Date.now()) {
|
|
375
|
+
return {
|
|
376
|
+
blocked: true,
|
|
377
|
+
blockedUntil: parseInt(blockExpiry)
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
blocked: false,
|
|
383
|
+
blockedUntil: null
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
} catch (error) {
|
|
387
|
+
logger.error({
|
|
388
|
+
code: 'MC_RATELIMIT_IS_BLOCKED_ERROR',
|
|
389
|
+
message: 'Failed to check block status',
|
|
390
|
+
identifier: identifier,
|
|
391
|
+
error: error.message
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
blocked: false,
|
|
395
|
+
blockedUntil: null,
|
|
396
|
+
error: true
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Middleware factory for MasterPipeline
|
|
403
|
+
*/
|
|
404
|
+
middleware(options = {}) {
|
|
405
|
+
const self = this;
|
|
406
|
+
const keyGenerator = options.keyGenerator || ((ctx) => {
|
|
407
|
+
// Default: use IP address
|
|
408
|
+
return ctx.request.connection.remoteAddress || 'unknown';
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return async (ctx, next) => {
|
|
412
|
+
try {
|
|
413
|
+
const identifier = keyGenerator(ctx);
|
|
414
|
+
const result = await self.consume(identifier);
|
|
415
|
+
|
|
416
|
+
// Add rate limit headers to response
|
|
417
|
+
ctx.response.setHeader('X-RateLimit-Limit', self.options.points);
|
|
418
|
+
ctx.response.setHeader('X-RateLimit-Remaining', result.remaining);
|
|
419
|
+
ctx.response.setHeader('X-RateLimit-Reset', new Date(result.resetAt).toISOString());
|
|
420
|
+
|
|
421
|
+
if (!result.allowed) {
|
|
422
|
+
// Rate limit exceeded
|
|
423
|
+
ctx.response.statusCode = 429;
|
|
424
|
+
ctx.response.setHeader('Content-Type', 'application/json');
|
|
425
|
+
ctx.response.setHeader('Retry-After', Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
426
|
+
|
|
427
|
+
const errorResponse = {
|
|
428
|
+
error: 'Too Many Requests',
|
|
429
|
+
message: 'Rate limit exceeded',
|
|
430
|
+
limit: self.options.points,
|
|
431
|
+
resetAt: new Date(result.resetAt).toISOString()
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
if (result.blocked) {
|
|
435
|
+
errorResponse.blocked = true;
|
|
436
|
+
errorResponse.message = 'Rate limit exceeded. Temporarily blocked.';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
ctx.response.end(JSON.stringify(errorResponse, null, 2));
|
|
440
|
+
return; // Don't call next()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Request allowed, continue pipeline
|
|
444
|
+
await next();
|
|
445
|
+
|
|
446
|
+
} catch (error) {
|
|
447
|
+
logger.error({
|
|
448
|
+
code: 'MC_RATELIMIT_MIDDLEWARE_ERROR',
|
|
449
|
+
message: 'Rate limit middleware error',
|
|
450
|
+
error: error.message
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// On error, allow request (fail open)
|
|
454
|
+
await next();
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
module.exports = {
|
|
461
|
+
RedisRateLimiter
|
|
462
|
+
};
|