← back to PoppyPetitions
middleware.ts
84 lines
import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
/**
* PoppyPetitions middleware — auth gate, request-id injection, rate-limit
* on Gemini-paid routes.
*
* 2026-05-05 (tick 35): ported from Patty's middleware.ts to bring Poppy
* to wave-3 parity. Collapses 10 copies of `verifyAuth(request); if (!user)
* return 401;` into one place. AI routes here are simulation-side (agents
* seeding bulk Gemini-generated content), so a runaway loop = real Gemini
* cost. 10/min/user limit is the v1 starting point.
*
* Runtime: Node (verifyAuth uses node:crypto). Next.js 16 supports Node
* runtime middleware natively.
*/
export const config = {
runtime: 'nodejs',
matcher: ['/api/:path*'],
};
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/agents/seed',
'/api/petitions', // POST creates petition via Gemini
]);
function isAiRoute(pathname: string): boolean {
if (AI_ROUTES.has(pathname)) return true;
// /api/petitions/[id]/vote and /api/petitions/[id]/comment both call Gemini
return /^\/api\/petitions\/[^/]+\/(vote|comment)$/.test(pathname);
}
// /api/health stays unauth (tick 28: stripped to opaque {status:'ok'} — uptime
// checkers can poll it without creds). Auth endpoints bypass the gate.
const PUBLIC_API = new Set([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/session',
'/api/health',
]);
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_API.has(pathname)) {
return injectRequestId(NextResponse.next(), request);
}
const username = verifyAuth(request);
if (!username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
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;
}