Some checks failed
Build pitch-deck / build-push-deploy (push) Has been cancelled
CI / go-lint (push) Has been cancelled
CI / python-lint (push) Has been cancelled
CI / nodejs-lint (push) Has been cancelled
CI / test-go-consent (push) Has been cancelled
CI / test-python-voice (push) Has been cancelled
CI / test-bqas (push) Has been cancelled
The SDK Live Demo slide renders screenshots via next/image from /public/screenshots/*.png. Because /screenshots was not on the PUBLIC_PATHS list, every request was 307-redirected to /auth, and the next/image optimizer responded with HTTP 400 "The requested resource isn't a valid image." leaving the slide with empty dark frames (surfaced in the pitch preview). next/image also bypasses middleware itself (see the matcher), but the server-side fetch it performs for the source URL does hit middleware and carries no investor cookie, so whitelisting the path is required even for authenticated viewers. These PNGs are public marketing assets — there's no reason to gate them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
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',
|
|
'/screenshots', // SDK demo screenshots: public marketing assets. Must bypass auth because the next/image optimizer fetches them server-side without investor cookies.
|
|
'/favicon.ico',
|
|
]
|
|
|
|
// Paths gated on the admin session cookie
|
|
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ----- Allow admins to access investor routes (e.g. /api/chat in preview) -----
|
|
const adminFallback = request.cookies.get('pitch_admin_session')?.value
|
|
if (adminFallback && secret) {
|
|
try {
|
|
await jwtVerify(adminFallback, new TextEncoder().encode(secret), { audience: ADMIN_AUDIENCE })
|
|
return NextResponse.next()
|
|
} catch {
|
|
// Invalid admin token, fall through to investor auth
|
|
}
|
|
}
|
|
|
|
// ----- 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).*)'],
|
|
}
|