← back to Grant

middleware.ts

136 lines

import { NextRequest, NextResponse } from 'next/server';
import { verifyAuthWithOrg } from '@/lib/auth';
import { getCapabilities } from '@/lib/accounts';

/**
 * Grant middleware — auth gate, request-id injection, rate-limit on AI routes.
 *
 * 2026-05-05 (architect-reviewer + tick 16): collapses 14 copies of
 * `verifyAuth(request); if (!user) return 401;` into one place. Adds
 * `x-request-id` for log correlation. Adds in-memory sliding-window
 * rate-limit on the 5 Gemini routes (P1-1 from code-reviewer).
 *
 * Runtime: Node (verifyAuth uses node:crypto). Next.js 16 supports
 * Node runtime middleware natively.
 */

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

// 2026-05-05 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
// counters live in a Map; entries expire after WINDOW_MS. This is the v1
// implementation — survives a single-process pm2 restart but not horizontal
// scale. Promote to PG `ai_call_log` if Grant ever moves multi-instance.
type Bucket = { count: number; resetAt: number };
const buckets: Map<string, Bucket> = new Map();
const WINDOW_MS = 60_000;
// 2026-05-30 (audit P1-3): tightened 10→5 for public exposure. Each Gemini
// call can cost ~$0.0025; a human won't fire 5 generations of one type per
// minute, but this caps automated/CSRF-driven cost abuse.
const AI_LIMIT_PER_MINUTE = 5;

const AI_ROUTES = new Set([
  '/api/grants/discover',
  '/api/collaborations/discover',
  '/api/news/discover',
  '/api/outreach/generate',
]);
function isAiRoute(pathname: string): boolean {
  if (AI_ROUTES.has(pathname)) return true;
  // /api/grants/[id]/proposals is the proposals AI route
  return /^\/api\/grants\/[^/]+\/proposals$/.test(pathname);
}

const PUBLIC_API = new Set([
  '/api/auth/login',
  '/api/auth/logout',
  '/api/auth/session',
]);

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

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

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

  // 2026-05-30 (audit P1-4): CSRF guard. The session cookie is SameSite=Lax,
  // which still permits cross-site fetch() POSTs. For state-mutating methods,
  // require the Origin header to match our own host (browsers always send
  // Origin on cross-origin and same-origin non-GET requests).
  if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method)) {
    const origin = request.headers.get('origin');
    if (origin) {
      let originHost: string;
      try { originHost = new URL(origin).host; } catch { originHost = ''; }
      const host = request.headers.get('host') ?? '';
      if (originHost !== host) {
        return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
      }
    }
  }

  // Capability gate (authoritative). Guest tiers are read-only / no-AI to
  // varying degrees; enforce here so a crafted request can't bypass the
  // hidden-in-UI affordances. Capabilities come from lib/accounts keyed by
  // the session username.
  const caps = getCapabilities(session.username);
  const isMutating = ['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method);

  if (isAiRoute(pathname)) {
    if (!caps.canUseAI) {
      return NextResponse.json(
        { error: 'AI features are not available for this account' },
        { status: 403 },
      );
    }
  } else if (isMutating) {
    if (!caps.canWrite) {
      return NextResponse.json(
        { error: 'This account is read-only' },
        { status: 403 },
      );
    }
  }

  // Rate-limit the Gemini-paid AI routes. Key by orgId+route so two users
  // in the same org share a quota; falls back to username for legacy v1
  // sessions where orgId is null.
  if (isAiRoute(pathname)) {
    const tenant = session.orgId ?? `u:${session.username}`;
    const key = `${tenant}:${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 {
  // 2026-05-30 (audit P2-2): only honor a client-supplied request-id if it's a
  // safe token; otherwise mint one. Stops log-injection via crafted header.
  const incoming = request.headers.get('x-request-id');
  const reqId = incoming && /^[A-Za-z0-9_-]{1,64}$/.test(incoming) ? incoming : crypto.randomUUID();
  res.headers.set('x-request-id', reqId);
  return res;
}