Files
breakpilot-core/pitch-deck/lib/auth.ts
Sharang Parnerkar c7ab569b2b
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s
feat(pitch-deck): admin UI for investor + financial-model management (#3)
Adds /pitch-admin dashboard with real bcrypt admin accounts and full
audit attribution for every state-changing action.

- pitch_admins + pitch_admin_sessions tables (migration 002)
- pitch_audit_logs.admin_id + target_investor_id columns
- lib/admin-auth.ts: bcryptjs, single-session, jose JWT with audience claim
- middleware.ts: two-cookie gating with bearer-secret CLI fallback
- 14 new API routes (admin-auth, dashboard, investor detail/edit/resend,
  admins CRUD, fm scenarios + assumptions PATCH)
- 9 admin pages: login, dashboard, investors list/new/[id], audit,
  financial-model list/[id], admins
- Bootstrap CLI: npm run admin:create
- 36 vitest tests covering auth, admin-auth, rate-limit primitives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:36:16 +00:00

164 lines
4.7 KiB
TypeScript

import { SignJWT, jwtVerify } from 'jose'
import { randomBytes, createHash } from 'crypto'
import { cookies } from 'next/headers'
import pool from './db'
const COOKIE_NAME = 'pitch_session'
const JWT_EXPIRY = '1h'
const SESSION_EXPIRY_HOURS = 24
function getJwtSecret() {
const secret = process.env.PITCH_JWT_SECRET
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
return new TextEncoder().encode(secret)
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function generateToken(): string {
return randomBytes(48).toString('hex')
}
export interface JwtPayload {
sub: string
email: string
sessionId: string
}
export async function createJwt(payload: JwtPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(JWT_EXPIRY)
.sign(getJwtSecret())
}
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
try {
const { payload } = await jwtVerify(token, getJwtSecret())
return payload as unknown as JwtPayload
} catch {
return null
}
}
export async function createSession(
investorId: string,
ip: string | null,
userAgent: string | null
): Promise<{ sessionId: string; jwt: string }> {
// Revoke all existing sessions for this investor (single session enforcement)
await pool.query(
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1 AND revoked = false`,
[investorId]
)
const sessionToken = generateToken()
const tokenHash = hashToken(sessionToken)
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
const { rows } = await pool.query(
`INSERT INTO pitch_sessions (investor_id, token_hash, ip_address, user_agent, expires_at)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[investorId, tokenHash, ip, userAgent, expiresAt]
)
const sessionId = rows[0].id
// Get investor email for JWT
const investor = await pool.query(
`SELECT email FROM pitch_investors WHERE id = $1`,
[investorId]
)
const jwt = await createJwt({
sub: investorId,
email: investor.rows[0].email,
sessionId,
})
return { sessionId, jwt }
}
export async function validateSession(sessionId: string, investorId: string): Promise<boolean> {
const { rows } = await pool.query(
`SELECT id FROM pitch_sessions
WHERE id = $1 AND investor_id = $2 AND revoked = false AND expires_at > NOW()`,
[sessionId, investorId]
)
return rows.length > 0
}
export async function revokeSession(sessionId: string): Promise<void> {
await pool.query(
`UPDATE pitch_sessions SET revoked = true WHERE id = $1`,
[sessionId]
)
}
export async function revokeAllSessions(investorId: string): Promise<void> {
await pool.query(
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1`,
[investorId]
)
}
export async function setSessionCookie(jwt: string): Promise<void> {
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_EXPIRY_HOURS * 60 * 60,
})
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(COOKIE_NAME)
}
export async function getSessionFromCookie(): Promise<JwtPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get(COOKIE_NAME)?.value
if (!token) return null
return verifyJwt(token)
}
export function getClientIp(request: Request): string | null {
const forwarded = request.headers.get('x-forwarded-for')
if (forwarded) return forwarded.split(',')[0].trim()
return null
}
export function validateAdminSecret(request: Request): boolean {
const secret = process.env.PITCH_ADMIN_SECRET
if (!secret) return false
const auth = request.headers.get('authorization')
if (!auth) return false
return auth === `Bearer ${secret}`
}
export async function logAudit(
investorId: string | null,
action: string,
details: Record<string, unknown> = {},
request?: Request,
slideId?: string,
sessionId?: string,
adminId?: string | null,
targetInvestorId?: string | null,
): Promise<void> {
const ip = request ? getClientIp(request) : null
const ua = request ? request.headers.get('user-agent') : null
await pool.query(
`INSERT INTO pitch_audit_logs
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
)
}