← 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);
}