← back to Hub
lib/rate-limit.ts
86 lines
// In-memory login rate-limit + brute-force lockout for the sister Next.js apps
// (Patty / Grant / Hub / Freddy). Mirrors the pattern shipped on Norma 2026-05-20,
// but trimmed to just the login-flavor helpers since these apps don't yet have
// keyed /api/v1/* surfaces.
//
// Single-process in-memory Map. Safe while each app runs as one PM2 process.
// For horizontal scaling later, swap the Map for Redis without touching callers.
import type { NextRequest } from 'next/server';
interface RateLimitEntry {
count: number;
resetTime: number; // epoch ms
}
const store = new Map<string, RateLimitEntry>();
const LOGIN_PREFIX = 'login:';
const LOGIN_MAX_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60_000; // 15 minutes
const LOGIN_LOCKOUT_MS = 30 * 60_000; // 30 minutes after the 10th fail
let lastCleanup = Date.now();
const CLEANUP_INTERVAL_MS = 60_000;
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
for (const [k, e] of store) if (e.resetTime <= now) store.delete(k);
}
export interface LoginAttemptResult {
locked: boolean;
retryAfter: number; // seconds
remaining: number;
}
export function loginClientIp(request: NextRequest): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}
export function checkLoginAttempt(ip: string): LoginAttemptResult {
cleanup();
const now = Date.now();
const entry = store.get(LOGIN_PREFIX + ip);
if (!entry || entry.resetTime <= now) {
return { locked: false, retryAfter: 0, remaining: LOGIN_MAX_ATTEMPTS };
}
if (entry.count >= LOGIN_MAX_ATTEMPTS) {
return { locked: true, retryAfter: Math.ceil((entry.resetTime - now) / 1000), remaining: 0 };
}
return {
locked: false,
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
remaining: LOGIN_MAX_ATTEMPTS - entry.count,
};
}
export function recordLoginFailure(ip: string): LoginAttemptResult {
cleanup();
const now = Date.now();
const key = LOGIN_PREFIX + ip;
let entry = store.get(key);
if (!entry || entry.resetTime <= now) {
entry = { count: 1, resetTime: now + LOGIN_WINDOW_MS };
store.set(key, entry);
} else {
entry.count += 1;
if (entry.count >= LOGIN_MAX_ATTEMPTS) entry.resetTime = now + LOGIN_LOCKOUT_MS;
}
const locked = entry.count >= LOGIN_MAX_ATTEMPTS;
return {
locked,
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
remaining: Math.max(0, LOGIN_MAX_ATTEMPTS - entry.count),
};
}
export function clearLoginCounter(ip: string): void {
store.delete(LOGIN_PREFIX + ip);
}