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