import { NextRequest, NextResponse } from 'next/server' import { jwtVerify } from 'jose' // Paths that bypass auth entirely const PUBLIC_PATHS = [ '/auth', // investor login pages '/api/auth', // investor auth API '/api/health', '/api/admin-auth', // admin login API '/pitch-admin/login', // admin login page '/_next', '/manifest.json', '/sw.js', '/icons', '/favicon.ico', ] // Paths gated on the admin session cookie const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin'] function isPublicPath(pathname: string): boolean { return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/')) } function isAdminGatedPath(pathname: string): boolean { return ADMIN_GATED_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/')) } const ADMIN_AUDIENCE = 'pitch-admin' export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl const secret = process.env.PITCH_JWT_SECRET // Allow public paths if (isPublicPath(pathname)) { return NextResponse.next() } // ----- Admin-gated routes ----- if (isAdminGatedPath(pathname)) { // Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves // also check this and log as actor='cli'). The bearer header is opaque to the JWT // path, so we just let it through here and let the route handler enforce. if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) { return NextResponse.next() } const adminToken = request.cookies.get('pitch_admin_session')?.value if (!adminToken || !secret) { if (pathname.startsWith('/api/')) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } return NextResponse.redirect(new URL('/pitch-admin/login', request.url)) } try { await jwtVerify(adminToken, new TextEncoder().encode(secret), { audience: ADMIN_AUDIENCE }) return NextResponse.next() } catch { if (pathname.startsWith('/api/')) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const response = NextResponse.redirect(new URL('/pitch-admin/login', request.url)) response.cookies.delete('pitch_admin_session') return response } } // ----- Investor-gated routes (everything else) ----- const token = request.cookies.get('pitch_session')?.value if (!token || !secret) { return NextResponse.redirect(new URL('/auth', request.url)) } try { const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)) const response = NextResponse.next() response.headers.set('x-investor-id', payload.sub as string) response.headers.set('x-investor-email', payload.email as string) response.headers.set('x-session-id', payload.sessionId as string) const exp = payload.exp as number const now = Math.floor(Date.now() / 1000) const timeLeft = exp - now if (timeLeft < 900 && timeLeft > 0) { response.headers.set('x-token-refresh-needed', 'true') } return response } catch { const response = NextResponse.redirect(new URL('/auth', request.url)) response.cookies.delete('pitch_session') return response } } export const config = { matcher: ['/((?!_next/static|_next/image).*)'], }