feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
385
mcp-server/rate-limit.js
Normal file
385
mcp-server/rate-limit.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Memento MCP Server - Rate Limiting
|
||||
*
|
||||
* Implements token bucket and sliding window rate limiting.
|
||||
* Per-user and global limits supported.
|
||||
*/
|
||||
|
||||
import config from './config.js';
|
||||
import { recordRateLimitBlocked } from './metrics.js';
|
||||
|
||||
/**
|
||||
* Rate limit entry for tracking usage
|
||||
*/
|
||||
class RateLimitEntry {
|
||||
constructor(windowMs, maxRequests) {
|
||||
this.resetTime = Date.now() + windowMs;
|
||||
this.maxRequests = maxRequests;
|
||||
this.requests = 0;
|
||||
this.windowMs = windowMs;
|
||||
}
|
||||
|
||||
increment() {
|
||||
// Check if window has expired
|
||||
if (Date.now() >= this.resetTime) {
|
||||
this.reset();
|
||||
}
|
||||
this.requests++;
|
||||
return this.requests <= this.maxRequests;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.requests = 0;
|
||||
this.resetTime = Date.now() + this.windowMs;
|
||||
}
|
||||
|
||||
get remaining() {
|
||||
if (Date.now() >= this.resetTime) {
|
||||
return this.maxRequests;
|
||||
}
|
||||
return Math.max(0, this.maxRequests - this.requests);
|
||||
}
|
||||
|
||||
get retryAfter() {
|
||||
if (Date.now() >= this.resetTime) {
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil((this.resetTime - Date.now()) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory rate limit storage
|
||||
*/
|
||||
class RateLimitStore {
|
||||
constructor() {
|
||||
this.limits = new Map();
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
startCleanup(intervalMs = 60000) {
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), intervalMs);
|
||||
}
|
||||
|
||||
stopCleanup() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of this.limits.entries()) {
|
||||
if (now >= entry.resetTime) {
|
||||
this.limits.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0 && config.logLevel === 'debug') {
|
||||
console.log(`[RATE_LIMIT] Cleaned ${cleaned} expired entries`);
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.limits.get(key);
|
||||
}
|
||||
|
||||
set(key, entry) {
|
||||
this.limits.set(key, entry);
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.limits.delete(key);
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.limits.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.limits.clear();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
totalEntries: this.limits.size,
|
||||
activeEntries: [...this.limits.values()].filter((e) => e.requests > 0).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global rate limit store
|
||||
*/
|
||||
const store = new RateLimitStore();
|
||||
store.startCleanup();
|
||||
|
||||
/**
|
||||
* Rate limiter class
|
||||
*/
|
||||
export class RateLimiter {
|
||||
constructor(options = {}) {
|
||||
this.windowMs = options.windowMs || config.rateLimitWindow;
|
||||
this.maxRequests = options.maxRequests || config.rateLimit;
|
||||
this.keyGenerator = options.keyGenerator || ((req) => req.userSession?.id || 'anonymous');
|
||||
this.skipSuccessfulRequests = options.skipSuccessfulRequests || false;
|
||||
this.skipFailedRequests = options.skipFailedRequests || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request should be rate limited
|
||||
*
|
||||
* @param {object} req - Request object
|
||||
* @returns {{ allowed: boolean, limit: number, remaining: number, resetTime: number, retryAfter?: number }}
|
||||
*/
|
||||
check(req) {
|
||||
const key = this.keyGenerator(req);
|
||||
let entry = store.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = new RateLimitEntry(this.windowMs, this.maxRequests);
|
||||
store.set(key, entry);
|
||||
}
|
||||
|
||||
const allowed = entry.increment();
|
||||
|
||||
return {
|
||||
allowed,
|
||||
limit: this.maxRequests,
|
||||
remaining: entry.remaining,
|
||||
resetTime: entry.resetTime,
|
||||
retryAfter: allowed ? undefined : entry.retryAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a specific key
|
||||
*
|
||||
* @param {string} key - Rate limit key
|
||||
*/
|
||||
reset(key) {
|
||||
store.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit info for a specific key
|
||||
*
|
||||
* @param {string} key - Rate limit key
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getInfo(key) {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
return {
|
||||
limit: this.maxRequests,
|
||||
remaining: entry.remaining,
|
||||
resetTime: entry.resetTime,
|
||||
retryAfter: entry.retryAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store statistics
|
||||
*/
|
||||
getStats() {
|
||||
return store.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined rate limiters
|
||||
*/
|
||||
|
||||
// Global rate limiter (applies to all requests)
|
||||
export const globalRateLimiter = new RateLimiter({
|
||||
windowMs: config.rateLimitWindow,
|
||||
maxRequests: config.rateLimit,
|
||||
keyGenerator: () => 'global',
|
||||
});
|
||||
|
||||
// Per-user rate limiter
|
||||
export const userRateLimiter = new RateLimiter({
|
||||
windowMs: config.rateLimitWindow,
|
||||
maxRequests: config.rateLimit,
|
||||
keyGenerator: (req) => `user:${req.userSession?.id || 'anonymous'}`,
|
||||
});
|
||||
|
||||
// Per-API-key rate limiter
|
||||
export const apiKeyRateLimiter = new RateLimiter({
|
||||
windowMs: config.rateLimitWindow,
|
||||
maxRequests: config.rateLimit,
|
||||
keyGenerator: (req) => `apikey:${req.headers['x-api-key'] || 'none'}`,
|
||||
});
|
||||
|
||||
// Per-tool rate limiter (more restrictive for expensive operations)
|
||||
export const toolRateLimiter = new RateLimiter({
|
||||
windowMs: config.rateLimitWindow,
|
||||
maxRequests: Math.max(10, Math.floor(config.rateLimit / 2)),
|
||||
keyGenerator: (req) => {
|
||||
const userId = req.userSession?.id || 'anonymous';
|
||||
// Extract tool name from request body
|
||||
const tool = req.body?.method || req.body?.tool || 'unknown';
|
||||
return `tool:${userId}:${tool}`;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Express middleware for rate limiting
|
||||
*
|
||||
* @param {RateLimiter} limiter - Rate limiter instance
|
||||
* @param {object} options - Middleware options
|
||||
*/
|
||||
export function rateLimitMiddleware(limiter, options = {}) {
|
||||
const {
|
||||
skipSuccessfulRequests = false,
|
||||
skipFailedRequests = false,
|
||||
onLimitReached = null,
|
||||
handler = null,
|
||||
} = options;
|
||||
|
||||
return (req, res, next) => {
|
||||
// Skip if rate limiting is disabled
|
||||
if (config.rateLimit <= 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const result = limiter.check(req);
|
||||
|
||||
// Add rate limit headers to response
|
||||
res.setHeader('X-RateLimit-Limit', result.limit);
|
||||
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
||||
res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString());
|
||||
|
||||
if (!result.allowed) {
|
||||
// Rate limit exceeded
|
||||
recordRateLimitBlocked(limiter.keyGenerator(req));
|
||||
|
||||
const identifier = limiter.keyGenerator(req);
|
||||
|
||||
if (handler) {
|
||||
return handler(req, res, result);
|
||||
}
|
||||
|
||||
res.setHeader('Retry-After', result.retryAfter.toString());
|
||||
return res.status(429).json({
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'Rate limit exceeded',
|
||||
detail: `Too many requests. Retry after ${result.retryAfter} seconds`,
|
||||
retryAfter: result.retryAfter,
|
||||
resetTime: new Date(result.resetTime).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Attach rate limit info to request for later use
|
||||
req.rateLimit = result;
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined rate limiting middleware
|
||||
* Applies all rate limiters (global, per-user, per-tool)
|
||||
*/
|
||||
export function combinedRateLimitMiddleware(req, res, next) {
|
||||
if (config.rateLimit <= 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check all limiters
|
||||
const limiters = [globalRateLimiter, userRateLimiter, apiKeyRateLimiter];
|
||||
|
||||
// For tool calls, also check tool-specific limiter
|
||||
if (req.body?.method || req.body?.tool) {
|
||||
limiters.push(toolRateLimiter);
|
||||
}
|
||||
|
||||
for (const limiter of limiters) {
|
||||
const result = limiter.check(req);
|
||||
|
||||
res.setHeader('X-RateLimit-Limit', result.limit);
|
||||
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
||||
res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString());
|
||||
|
||||
if (!result.allowed) {
|
||||
recordRateLimitBlocked(limiter.keyGenerator(req));
|
||||
|
||||
res.setHeader('Retry-After', result.retryAfter.toString());
|
||||
return res.status(429).json({
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'Rate limit exceeded',
|
||||
detail: `Too many requests. Retry after ${result.retryAfter} seconds`,
|
||||
retryAfter: result.retryAfter,
|
||||
resetTime: new Date(result.resetTime).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limits for a user/session
|
||||
*
|
||||
* @param {string} identifier - User ID or API key
|
||||
*/
|
||||
export function resetRateLimit(identifier) {
|
||||
store.delete(`user:${identifier}`);
|
||||
store.delete(`apikey:${identifier}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rate limit stats
|
||||
*/
|
||||
export function getRateLimitStats() {
|
||||
return {
|
||||
store: store.getStats(),
|
||||
config: {
|
||||
windowMs: config.rateLimitWindow,
|
||||
maxRequests: config.rateLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate limits (useful for testing)
|
||||
*/
|
||||
export function clearAllRateLimits() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop rate limiter cleanup interval
|
||||
*/
|
||||
export function shutdown() {
|
||||
store.stopCleanup();
|
||||
}
|
||||
|
||||
// Auto-start cleanup on module load
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
export default {
|
||||
RateLimiter,
|
||||
globalRateLimiter,
|
||||
userRateLimiter,
|
||||
apiKeyRateLimiter,
|
||||
toolRateLimiter,
|
||||
rateLimitMiddleware,
|
||||
combinedRateLimitMiddleware,
|
||||
resetRateLimit,
|
||||
getRateLimitStats,
|
||||
clearAllRateLimits,
|
||||
shutdown,
|
||||
};
|
||||
Reference in New Issue
Block a user