feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

385
mcp-server/rate-limit.js Normal file
View 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,
};