import { SignJWT, jwtVerify } from 'jose' import bcrypt from 'bcryptjs' import { cookies } from 'next/headers' import { NextResponse } from 'next/server' import pool from './db' import { hashToken, generateToken, getClientIp, logAudit } from './auth' const ADMIN_COOKIE_NAME = 'pitch_admin_session' const ADMIN_JWT_AUDIENCE = 'pitch-admin' const ADMIN_JWT_EXPIRY = '2h' const ADMIN_SESSION_EXPIRY_HOURS = 12 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 interface Admin { id: string email: string name: string is_active: boolean last_login_at: string | null created_at: string } export interface AdminJwtPayload { sub: string // admin id email: string sessionId: string } export async function hashPassword(password: string): Promise { return bcrypt.hash(password, 12) } export async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash) } export async function createAdminJwt(payload: AdminJwtPayload): Promise { return new SignJWT({ ...payload }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(ADMIN_JWT_EXPIRY) .setAudience(ADMIN_JWT_AUDIENCE) .sign(getJwtSecret()) } export async function verifyAdminJwt(token: string): Promise { try { const { payload } = await jwtVerify(token, getJwtSecret(), { audience: ADMIN_JWT_AUDIENCE }) return payload as unknown as AdminJwtPayload } catch { return null } } export async function createAdminSession( adminId: string, ip: string | null, userAgent: string | null, ): Promise<{ sessionId: string; jwt: string }> { // Single session per admin await pool.query( `UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1 AND revoked = false`, [adminId], ) const sessionToken = generateToken() const tokenHash = hashToken(sessionToken) const expiresAt = new Date(Date.now() + ADMIN_SESSION_EXPIRY_HOURS * 60 * 60 * 1000) const { rows } = await pool.query( `INSERT INTO pitch_admin_sessions (admin_id, token_hash, ip_address, user_agent, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [adminId, tokenHash, ip, userAgent, expiresAt], ) const sessionId = rows[0].id const adminRes = await pool.query(`SELECT email FROM pitch_admins WHERE id = $1`, [adminId]) const jwt = await createAdminJwt({ sub: adminId, email: adminRes.rows[0].email, sessionId, }) return { sessionId, jwt } } export async function validateAdminSession(sessionId: string, adminId: string): Promise { const { rows } = await pool.query( `SELECT s.id FROM pitch_admin_sessions s JOIN pitch_admins a ON a.id = s.admin_id WHERE s.id = $1 AND s.admin_id = $2 AND s.revoked = false AND s.expires_at > NOW() AND a.is_active = true`, [sessionId, adminId], ) return rows.length > 0 } export async function revokeAdminSession(sessionId: string): Promise { await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId]) } export async function revokeAllAdminSessions(adminId: string): Promise { await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId]) } export async function setAdminCookie(jwt: string): Promise { const cookieStore = await cookies() cookieStore.set(ADMIN_COOKIE_NAME, jwt, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: ADMIN_SESSION_EXPIRY_HOURS * 60 * 60, }) } export async function clearAdminCookie(): Promise { const cookieStore = await cookies() cookieStore.delete(ADMIN_COOKIE_NAME) } export async function getAdminPayloadFromCookie(): Promise { const cookieStore = await cookies() const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value if (!token) return null return verifyAdminJwt(token) } /** * Server-side: read the admin row from the cookie. Returns null if no valid session * or the admin is inactive. Use in layout.tsx and API routes. */ export async function getAdminFromCookie(): Promise { const payload = await getAdminPayloadFromCookie() if (!payload) return null const valid = await validateAdminSession(payload.sessionId, payload.sub) if (!valid) return null const { rows } = await pool.query( `SELECT id, email, name, is_active, last_login_at, created_at FROM pitch_admins WHERE id = $1`, [payload.sub], ) if (rows.length === 0 || !rows[0].is_active) return null return rows[0] as Admin } /** * API guard: returns the Admin row, OR a NextResponse 401/403 to return early. * Also accepts the legacy PITCH_ADMIN_SECRET bearer header for CLI/automation — * in that case the returned admin id is null but the request is allowed. */ export type AdminGuardResult = | { kind: 'admin'; admin: Admin } | { kind: 'cli' } | { kind: 'response'; response: NextResponse } export async function requireAdmin(request: Request): Promise { // CLI fallback via shared secret const secret = process.env.PITCH_ADMIN_SECRET if (secret) { const auth = request.headers.get('authorization') if (auth === `Bearer ${secret}`) { return { kind: 'cli' } } } const admin = await getAdminFromCookie() if (!admin) { return { kind: 'response', response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), } } return { kind: 'admin', admin } } /** * Convenience: log an admin-initiated audit event. Falls back to CLI actor when admin is null. */ export async function logAdminAudit( adminId: string | null, action: string, details: Record = {}, request?: Request, targetInvestorId?: string | null, ): Promise { await logAudit( null, // investor_id action, details, request, undefined, // slide_id undefined, // session_id adminId, targetInvestorId ?? null, ) } export { ADMIN_COOKIE_NAME }