← back to Patty
middleware.ts
112 lines
import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { capabilityGate } from '@dw/nextjs-admin-login';
import { registry } from '@/lib/accounts';
/**
* Patty middleware — auth gate, request-id injection, rate-limit on AI routes.
*
* 2026-05-05 (architect-reviewer + tick 27): collapses 40 copies of
* `verifyAuth(request); if (!user) return 401;` across 23 routes into one
* place. Adds `x-request-id` for log correlation. Adds in-memory sliding-
* window rate-limit on the 7 Gemini routes (was 4 in Grant; Patty has
* more AI surface area).
*
* Key difference from Grant's middleware: Patty stays on the legacy
* `verifyAuth` (returns username string) — Patty's data model is single-
* tenant per-user (no organizations table), so the orgId fast-path Grant
* uses doesn't apply yet. Architect explicitly recommended SKIP for now.
*
* 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. v1 implementation —
// survives a single-process pm2 restart but not horizontal scale.
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/petitions/generate',
'/api/campaigns/generate',
'/api/trending/discover',
'/api/orbit/rss',
'/api/orbit/suggest-alliance',
'/api/orbit/batch-link',
'/api/orbit/generate',
]);
function isAiRoute(pathname: string): boolean {
return AI_ROUTES.has(pathname);
}
// 2026-05-05 (architect P1): public-bypass set INCLUDES /api/public/signatures
// — Patty's only unauthenticated write endpoint (signature submission from
// petition pages). Don't gate it behind auth or no one can sign.
const PUBLIC_API = new Set([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/session',
'/api/public/signatures',
'/api/health',
]);
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;
}