← back to Freddy

middleware.ts

107 lines

import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { capabilityGate } from '@dw/nextjs-admin-login';
import { registry } from '@/lib/accounts';

/**
 * Freddy middleware — auth gate, request-id injection, rate-limit on AI routes.
 *
 * 2026-05-30 (tick 11 from TODO.md): ported after the soak window from Patty
 * (canonical) and Grant. Centralizes the (verifyAuth → 401) pattern that was
 * duplicated across every /api route. Adds `x-request-id` for log correlation.
 * Adds in-memory sliding-window rate-limit on Freddy's 4 Gemini routes
 * (causes/discover, matches/generate, contacts/discover, contacts/generate-letter).
 *
 * Like Patty, Freddy is single-tenant per-user (no organizations table yet),
 * so it stays on legacy `verifyAuth` (returns username string). Adopt
 * `verifyAuthWithOrg` later if/when a granter-org model lands.
 *
 * Runtime: Node (verifyAuth uses node:crypto). Next.js 15+ supports Node-runtime
 * middleware natively.
 */

export const config = {
  runtime: 'nodejs',
  matcher: ['/api/:path*'],
};

// 2026-05-30 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
// counters live in a Map; entries expire after WINDOW_MS. v1 implementation —
// survives a single-process pm2 restart but not horizontal scale. Mirrors
// Patty's pattern exactly so the swap-to-Redis story is uniform across the fleet.
type Bucket = { count: number; resetAt: number };
const buckets: Map<string, Bucket> = new Map();
const WINDOW_MS = 60_000;
const AI_LIMIT_PER_MINUTE = 10;

const AI_ROUTES = new Set([
  '/api/causes/discover',
  '/api/matches/generate',
  '/api/contacts/discover',
  '/api/contacts/generate-letter',
]);
function isAiRoute(pathname: string): boolean {
  return AI_ROUTES.has(pathname);
}

// Freddy has no public unauthenticated API yet (no equivalent to Patty's
// /api/public/signatures). Keep this list narrow on purpose — every new entry
// is an unauthenticated write surface that needs explicit review.
const PUBLIC_API = new Set([
  '/api/auth/login',
  '/api/auth/logout',
  '/api/auth/session',
]);

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Public endpoints bypass the gate.
  if (PUBLIC_API.has(pathname)) {
    return injectRequestId(NextResponse.next(), request);
  }

  // Auth gate for everything else under /api/**.
  const username = verifyAuth(request);
  if (!username) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Capability gate (authoritative): read-only / no-AI guest tiers can't
  // mutate or hit AI routes regardless of what the UI shows.
  const gateResult = capabilityGate({
    capabilities: registry.getCapabilities(username),
    method: request.method,
    isAiRoute: isAiRoute(pathname),
  });
  if (!gateResult.ok) {
    return NextResponse.json({ error: gateResult.error }, { status: gateResult.status });
  }

  // Rate-limit the Gemini-paid AI routes. Key by username (single-tenant).
  if (isAiRoute(pathname)) {
    const key = `${username}:${pathname}`;
    const now = Date.now();
    const b = buckets.get(key);
    if (!b || b.resetAt < now) {
      buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
    } else if (b.count >= AI_LIMIT_PER_MINUTE) {
      return NextResponse.json(
        { error: 'rate_limited', retry_after_ms: b.resetAt - now },
        { status: 429, headers: { 'retry-after': Math.ceil((b.resetAt - now) / 1000).toString() } },
      );
    } else {
      b.count++;
    }
  }

  return injectRequestId(NextResponse.next(), request);
}

function injectRequestId(res: NextResponse, request: NextRequest): NextResponse {
  const incoming = request.headers.get('x-request-id');
  const reqId = incoming || crypto.randomUUID();
  res.headers.set('x-request-id', reqId);
  return res;
}