/** * Submission Rate Limiter * * Client-side rate limiting for submission creation to prevent * abuse and accidental duplicate submissions. * * Part of Sacred Pipeline Phase 3: Enhanced Error Handling */ import { logger } from './logger'; interface RateLimitConfig { maxSubmissionsPerMinute: number; maxSubmissionsPerHour: number; cooldownAfterLimit: number; // milliseconds } interface RateLimitRecord { timestamps: number[]; lastAttempt: number; blockedUntil?: number; } const DEFAULT_CONFIG: RateLimitConfig = { maxSubmissionsPerMinute: 5, maxSubmissionsPerHour: 20, cooldownAfterLimit: 60000, // 1 minute }; // Store rate limit data in memory (per session) const rateLimitStore = new Map(); /** * Clean up old timestamps from rate limit record */ function cleanupTimestamps(record: RateLimitRecord, now: number): void { const oneHourAgo = now - 60 * 60 * 1000; record.timestamps = record.timestamps.filter(ts => ts > oneHourAgo); } /** * Get or create rate limit record for user */ function getRateLimitRecord(userId: string): RateLimitRecord { if (!rateLimitStore.has(userId)) { rateLimitStore.set(userId, { timestamps: [], lastAttempt: 0, }); } return rateLimitStore.get(userId)!; } /** * Check if user can submit based on rate limits * * @param userId - User ID to check * @param config - Optional rate limit configuration * @returns Object indicating if allowed and retry information */ export function checkSubmissionRateLimit( userId: string, config: Partial = {} ): { allowed: boolean; reason?: string; retryAfter?: number; // seconds remaining?: number; } { const cfg = { ...DEFAULT_CONFIG, ...config }; const now = Date.now(); const record = getRateLimitRecord(userId); // Clean up old timestamps cleanupTimestamps(record, now); // Check if user is currently blocked if (record.blockedUntil && now < record.blockedUntil) { const retryAfter = Math.ceil((record.blockedUntil - now) / 1000); logger.warn('[SubmissionRateLimiter] User blocked', { userId, retryAfter, }); return { allowed: false, reason: `Rate limit exceeded. Please wait ${retryAfter} seconds before submitting again`, retryAfter, }; } // Check per-minute limit const oneMinuteAgo = now - 60 * 1000; const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length; if (submissionsLastMinute >= cfg.maxSubmissionsPerMinute) { record.blockedUntil = now + cfg.cooldownAfterLimit; const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000); logger.warn('[SubmissionRateLimiter] Per-minute limit exceeded', { userId, submissionsLastMinute, limit: cfg.maxSubmissionsPerMinute, retryAfter, }); return { allowed: false, reason: `Too many submissions in a short time. Please wait ${retryAfter} seconds`, retryAfter, }; } // Check per-hour limit const submissionsLastHour = record.timestamps.length; if (submissionsLastHour >= cfg.maxSubmissionsPerHour) { record.blockedUntil = now + cfg.cooldownAfterLimit; const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000); logger.warn('[SubmissionRateLimiter] Per-hour limit exceeded', { userId, submissionsLastHour, limit: cfg.maxSubmissionsPerHour, retryAfter, }); return { allowed: false, reason: `Hourly submission limit reached. Please wait ${retryAfter} seconds`, retryAfter, }; } // Calculate remaining submissions const remainingMinute = cfg.maxSubmissionsPerMinute - submissionsLastMinute; const remainingHour = cfg.maxSubmissionsPerHour - submissionsLastHour; const remaining = Math.min(remainingMinute, remainingHour); return { allowed: true, remaining, }; } /** * Record a submission attempt * * @param userId - User ID */ export function recordSubmissionAttempt(userId: string): void { const now = Date.now(); const record = getRateLimitRecord(userId); record.timestamps.push(now); record.lastAttempt = now; // Clean up immediately to maintain accurate counts cleanupTimestamps(record, now); logger.info('[SubmissionRateLimiter] Recorded submission', { userId, totalLastHour: record.timestamps.length, }); } /** * Clear rate limit for user (useful for testing or admin override) * * @param userId - User ID to clear */ export function clearUserRateLimit(userId: string): void { rateLimitStore.delete(userId); logger.info('[SubmissionRateLimiter] Cleared rate limit', { userId }); } /** * Get current rate limit status for user * * @param userId - User ID * @returns Current status information */ export function getRateLimitStatus(userId: string): { submissionsLastMinute: number; submissionsLastHour: number; isBlocked: boolean; blockedUntil?: Date; } { const now = Date.now(); const record = getRateLimitRecord(userId); cleanupTimestamps(record, now); const oneMinuteAgo = now - 60 * 1000; const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length; return { submissionsLastMinute, submissionsLastHour: record.timestamps.length, isBlocked: !!(record.blockedUntil && now < record.blockedUntil), blockedUntil: record.blockedUntil ? new Date(record.blockedUntil) : undefined, }; }