import type { Context, MiddlewareHandler } from 'hono'; import * as errors from '../custom-errors'; type RateLimitEntry = { count: number; resetAt: number; }; type RateLimiterOptions = { windowMs: number; max: number; keyGenerator?: (ctx: Context) => string; message?: string; }; const stores = new Map>(); function cleanupStore(store: Map) { const now = Date.now(); for (const [key, entry] of store) { if (entry.resetAt <= now) { store.delete(key); } } } const { PUBLIC_BUILD_ENV } = process.env; const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging'; export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler { const { windowMs, max, keyGenerator = (ctx) => ctx.req.header('x-forwarded-for') || 'unknown', message = 'Too many requests, please try again later', } = options; const storeId = `${windowMs}-${max}-${Math.random()}`; const store = new Map(); stores.set(storeId, store); // Cleanup every minute setInterval(() => cleanupStore(store), 60_000); return async (ctx, next) => { if (!isProduction) return next(); const key = keyGenerator(ctx); const now = Date.now(); const entry = store.get(key); if (entry && entry.resetAt > now && entry.count >= max) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000); throw errors.TOO_MANY_REQUESTS(message, retryAfter); } await next(); // Only count failed attempts (4xx status codes) const status = ctx.res.status; if (status >= 400 && status < 500) { if (entry && entry.resetAt > now) { entry.count++; } else { store.set(key, { count: 1, resetAt: now + windowMs }); } } }; } // Pre-configured rate limiters for common use cases export const signinRateLimiter = rateLimiter({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, keyGenerator: (ctx) => { const body = ctx.get('body'); return body?.email || ctx.req.header('x-forwarded-for') || 'unknown'; }, message: 'Too many sign in attempts, please try again in 15 minutes', }); export const forgotPasswordRateLimiter = rateLimiter({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, keyGenerator: (ctx) => { const body = ctx.get('body'); return body?.email || ctx.req.header('x-forwarded-for') || 'unknown'; }, message: 'Too many password reset requests, please try again in 1 hour', }); export const signupRateLimiter = rateLimiter({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, keyGenerator: (ctx) => ctx.req.header('x-forwarded-for') || 'unknown', message: 'Too many sign up attempts, please try again in 1 hour', }); export const passkeyRateLimiter = rateLimiter({ windowMs: 60 * 1000, // 1 minute max: 10, keyGenerator: (ctx) => { const email = ctx.req.param('email'); return email || ctx.req.header('x-forwarded-for') || 'unknown'; }, message: 'Too many passkey requests, please try again in 1 minute', });