Files
breakpilot-core/pitch-deck/middleware.ts
Sharang Parnerkar 44659a9dd7
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
fix(pitch-deck): serve /screenshots/* past the auth middleware
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>
2026-04-15 18:20:16 +02:00

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).*)'],
}