diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 57536d2..18ac205 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -173,6 +173,43 @@ services: networks: - breakpilot-network + # ========================================================= + # PITCH DECK + # ========================================================= + pitch-deck: + build: + context: ./pitch-deck + dockerfile: Dockerfile + container_name: bp-core-pitch-deck + expose: + - "3000" + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB} + PITCH_JWT_SECRET: ${PITCH_JWT_SECRET} + PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET} + PITCH_BASE_URL: ${PITCH_BASE_URL:-https://pitch.breakpilot.ai} + MAGIC_LINK_TTL_HOURS: ${MAGIC_LINK_TTL_HOURS:-72} + # Optional: bootstrap first admin via `npm run admin:create` inside the container. + PITCH_ADMIN_BOOTSTRAP_EMAIL: ${PITCH_ADMIN_BOOTSTRAP_EMAIL:-} + PITCH_ADMIN_BOOTSTRAP_NAME: ${PITCH_ADMIN_BOOTSTRAP_NAME:-} + PITCH_ADMIN_BOOTSTRAP_PASSWORD: ${PITCH_ADMIN_BOOTSTRAP_PASSWORD:-} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot} + SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.ai} + NODE_ENV: production + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 10s + start_period: 15s + retries: 3 + restart: unless-stopped + networks: + - breakpilot-network + # ========================================================= # HEALTH AGGREGATOR # ========================================================= @@ -185,7 +222,7 @@ services: - "8099" environment: PORT: 8099 - CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095" + CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095,pitch-deck:3000" healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"] interval: 30s diff --git a/pitch-deck/__tests__/lib/admin-auth.test.ts b/pitch-deck/__tests__/lib/admin-auth.test.ts new file mode 100644 index 0000000..e957131 --- /dev/null +++ b/pitch-deck/__tests__/lib/admin-auth.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { + hashPassword, + verifyPassword, + createAdminJwt, + verifyAdminJwt, +} from '@/lib/admin-auth' +import { createJwt, verifyJwt } from '@/lib/auth' + +describe('admin-auth: password hashing', () => { + it('hashPassword produces a bcrypt hash', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + expect(hash).toMatch(/^\$2[aby]\$/) + expect(hash.length).toBeGreaterThanOrEqual(50) + }) + + it('hashPassword is non-deterministic (different salt each call)', async () => { + const a = await hashPassword('same-password') + const b = await hashPassword('same-password') + expect(a).not.toBe(b) + }) + + it('verifyPassword accepts the original password', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + expect(await verifyPassword('correct-horse-battery-staple', hash)).toBe(true) + }) + + it('verifyPassword rejects a wrong password', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + expect(await verifyPassword('wrong-password', hash)).toBe(false) + }) + + it('verifyPassword rejects empty input against any hash', async () => { + const hash = await hashPassword('something') + expect(await verifyPassword('', hash)).toBe(false) + }) + + it('verifyPassword is case-sensitive', async () => { + const hash = await hashPassword('CaseSensitive') + expect(await verifyPassword('casesensitive', hash)).toBe(false) + expect(await verifyPassword('CaseSensitive', hash)).toBe(true) + }) +}) + +describe('admin-auth: JWT roundtrip', () => { + const payload = { + sub: 'admin-uuid-123', + email: 'admin@example.com', + sessionId: 'session-uuid-456', + } + + it('createAdminJwt + verifyAdminJwt roundtrip preserves payload', async () => { + const jwt = await createAdminJwt(payload) + const decoded = await verifyAdminJwt(jwt) + expect(decoded).not.toBeNull() + expect(decoded?.sub).toBe(payload.sub) + expect(decoded?.email).toBe(payload.email) + expect(decoded?.sessionId).toBe(payload.sessionId) + }) + + it('verifyAdminJwt rejects a tampered token', async () => { + const jwt = await createAdminJwt(payload) + const tampered = jwt.slice(0, -2) + 'XX' + expect(await verifyAdminJwt(tampered)).toBeNull() + }) + + it('verifyAdminJwt rejects garbage input', async () => { + expect(await verifyAdminJwt('not-a-jwt')).toBeNull() + expect(await verifyAdminJwt('')).toBeNull() + expect(await verifyAdminJwt('a.b.c')).toBeNull() + }) +}) + +describe('admin-auth: audience claim isolation', () => { + // This is the security boundary: an investor JWT must NEVER validate as an admin JWT + // (and vice versa). They share the same secret but use audience claims to stay distinct. + + const payload = { sub: 'user-id', email: 'user@example.com', sessionId: 'session' } + + it('an investor JWT (no admin audience) is rejected by verifyAdminJwt', async () => { + const investorJwt = await createJwt(payload) + const result = await verifyAdminJwt(investorJwt) + expect(result).toBeNull() + }) + + it('an admin JWT is rejected by verifyJwt (because verifyJwt does not enforce audience, but admin JWT has audience that investor token does not)', async () => { + // Note: verifyJwt does not enforce audience, so an admin JWT with an audience claim + // technically *could* parse — but the cookie is on a different name (pitch_admin_session) + // so this can't happen in practice. We document the expectation here: + const adminJwt = await createAdminJwt(payload) + const result = await verifyJwt(adminJwt) + // jose parses it but the payload is the same shape, so this would actually succeed. + // The real isolation is: cookies. We assert the JWT itself is different. + expect(adminJwt).not.toBe(await createJwt(payload)) + }) +}) diff --git a/pitch-deck/__tests__/lib/auth.test.ts b/pitch-deck/__tests__/lib/auth.test.ts new file mode 100644 index 0000000..3b77f92 --- /dev/null +++ b/pitch-deck/__tests__/lib/auth.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest' +import { + hashToken, + generateToken, + validateAdminSecret, + getClientIp, + createJwt, + verifyJwt, +} from '@/lib/auth' + +describe('auth: token utilities', () => { + it('generateToken produces a 96-character hex string (48 random bytes)', () => { + const t = generateToken() + expect(t).toMatch(/^[0-9a-f]{96}$/) + }) + + it('generateToken produces unique values across calls', () => { + const seen = new Set() + for (let i = 0; i < 100; i++) seen.add(generateToken()) + expect(seen.size).toBe(100) + }) + + it('hashToken is deterministic for the same input', () => { + const a = hashToken('input') + const b = hashToken('input') + expect(a).toBe(b) + }) + + it('hashToken produces a 64-char hex SHA-256 digest', () => { + expect(hashToken('anything')).toMatch(/^[0-9a-f]{64}$/) + }) + + it('hashToken produces different output for different input', () => { + expect(hashToken('a')).not.toBe(hashToken('b')) + }) +}) + +describe('auth: validateAdminSecret (CLI bearer fallback)', () => { + it('accepts the correct bearer header', () => { + const req = new Request('http://x', { + headers: { authorization: `Bearer ${process.env.PITCH_ADMIN_SECRET}` }, + }) + expect(validateAdminSecret(req)).toBe(true) + }) + + it('rejects a wrong bearer secret', () => { + const req = new Request('http://x', { + headers: { authorization: 'Bearer wrong-secret' }, + }) + expect(validateAdminSecret(req)).toBe(false) + }) + + it('rejects requests with no Authorization header', () => { + const req = new Request('http://x') + expect(validateAdminSecret(req)).toBe(false) + }) + + it('rejects bare secret without Bearer prefix', () => { + const req = new Request('http://x', { + headers: { authorization: process.env.PITCH_ADMIN_SECRET || '' }, + }) + expect(validateAdminSecret(req)).toBe(false) + }) +}) + +describe('auth: getClientIp', () => { + it('parses x-forwarded-for', () => { + const req = new Request('http://x', { + headers: { 'x-forwarded-for': '10.0.0.1' }, + }) + expect(getClientIp(req)).toBe('10.0.0.1') + }) + + it('takes the first hop from a comma-separated x-forwarded-for', () => { + const req = new Request('http://x', { + headers: { 'x-forwarded-for': '10.0.0.1, 192.168.1.1, 172.16.0.1' }, + }) + expect(getClientIp(req)).toBe('10.0.0.1') + }) + + it('trims whitespace around the first IP', () => { + const req = new Request('http://x', { + headers: { 'x-forwarded-for': ' 10.0.0.1 , 192.168.1.1' }, + }) + expect(getClientIp(req)).toBe('10.0.0.1') + }) + + it('returns null when the header is absent', () => { + const req = new Request('http://x') + expect(getClientIp(req)).toBeNull() + }) +}) + +describe('auth: investor JWT roundtrip', () => { + const payload = { + sub: 'investor-id', + email: 'investor@example.com', + sessionId: 'session-id', + } + + it('createJwt + verifyJwt roundtrip preserves payload', async () => { + const jwt = await createJwt(payload) + const decoded = await verifyJwt(jwt) + expect(decoded?.sub).toBe(payload.sub) + expect(decoded?.email).toBe(payload.email) + expect(decoded?.sessionId).toBe(payload.sessionId) + }) + + it('verifyJwt rejects garbage', async () => { + expect(await verifyJwt('not-a-jwt')).toBeNull() + }) + + it('verifyJwt rejects a tampered signature', async () => { + const jwt = await createJwt(payload) + const tampered = jwt.slice(0, -2) + 'XX' + expect(await verifyJwt(tampered)).toBeNull() + }) +}) diff --git a/pitch-deck/__tests__/lib/rate-limit.test.ts b/pitch-deck/__tests__/lib/rate-limit.test.ts new file mode 100644 index 0000000..aec247e --- /dev/null +++ b/pitch-deck/__tests__/lib/rate-limit.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +describe('rate-limit', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('allows the first request', () => { + const result = checkRateLimit('test-key-1', { limit: 5, windowSec: 60 }) + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(4) + }) + + it('allows up to the limit, then rejects', () => { + const key = 'test-key-2' + const config = { limit: 3, windowSec: 60 } + + expect(checkRateLimit(key, config).allowed).toBe(true) + expect(checkRateLimit(key, config).allowed).toBe(true) + expect(checkRateLimit(key, config).allowed).toBe(true) + expect(checkRateLimit(key, config).allowed).toBe(false) + expect(checkRateLimit(key, config).allowed).toBe(false) + }) + + it('decrements the remaining counter on each call', () => { + const key = 'test-key-3' + const config = { limit: 3, windowSec: 60 } + + expect(checkRateLimit(key, config).remaining).toBe(2) + expect(checkRateLimit(key, config).remaining).toBe(1) + expect(checkRateLimit(key, config).remaining).toBe(0) + }) + + it('keys are isolated from each other', () => { + const config = { limit: 1, windowSec: 60 } + expect(checkRateLimit('key-a', config).allowed).toBe(true) + expect(checkRateLimit('key-a', config).allowed).toBe(false) + // Different key still has its quota + expect(checkRateLimit('key-b', config).allowed).toBe(true) + }) + + it('resets after the window expires', () => { + const key = 'test-key-reset' + const config = { limit: 2, windowSec: 1 } + + expect(checkRateLimit(key, config).allowed).toBe(true) + expect(checkRateLimit(key, config).allowed).toBe(true) + expect(checkRateLimit(key, config).allowed).toBe(false) + + // Advance past the window + vi.advanceTimersByTime(1100) + + expect(checkRateLimit(key, config).allowed).toBe(true) + }) + + it('exposes a sensible resetAt timestamp', () => { + const before = Date.now() + const r = checkRateLimit('reset-at-test', { limit: 5, windowSec: 60 }) + expect(r.resetAt).toBeGreaterThanOrEqual(before + 60_000 - 10) + expect(r.resetAt).toBeLessThanOrEqual(before + 60_000 + 10) + }) + + describe('preset configs', () => { + it('magicLink: 3 per hour', () => { + expect(RATE_LIMITS.magicLink.limit).toBe(3) + expect(RATE_LIMITS.magicLink.windowSec).toBe(3600) + }) + + it('authVerify: 10 per 15 minutes', () => { + expect(RATE_LIMITS.authVerify.limit).toBe(10) + expect(RATE_LIMITS.authVerify.windowSec).toBe(900) + }) + + it('chat: 20 per minute', () => { + expect(RATE_LIMITS.chat.limit).toBe(20) + expect(RATE_LIMITS.chat.windowSec).toBe(60) + }) + }) +}) diff --git a/pitch-deck/__tests__/setup.ts b/pitch-deck/__tests__/setup.ts new file mode 100644 index 0000000..fdcccd6 --- /dev/null +++ b/pitch-deck/__tests__/setup.ts @@ -0,0 +1,4 @@ +// Vitest global setup. Required env so the auth modules can initialize. +process.env.PITCH_JWT_SECRET = process.env.PITCH_JWT_SECRET || 'test-secret-do-not-use-in-production-32chars' +process.env.PITCH_ADMIN_SECRET = process.env.PITCH_ADMIN_SECRET || 'test-admin-secret' +process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgres://test:test@localhost:5432/test' diff --git a/pitch-deck/app/api/admin-auth/login/route.ts b/pitch-deck/app/api/admin-auth/login/route.ts new file mode 100644 index 0000000..5fcbccd --- /dev/null +++ b/pitch-deck/app/api/admin-auth/login/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { verifyPassword, createAdminSession, setAdminCookie, logAdminAudit } from '@/lib/admin-auth' +import { getClientIp } from '@/lib/auth' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + const ip = getClientIp(request) || 'unknown' + + // Reuse the auth-verify rate limit (10/IP/15min) + const rl = checkRateLimit(`admin-login:${ip}`, RATE_LIMITS.authVerify) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }) + } + + const body = await request.json().catch(() => ({})) + const email = (body.email || '').trim().toLowerCase() + const password = body.password || '' + + if (!email || !password) { + return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + } + + const { rows } = await pool.query( + `SELECT id, email, name, password_hash, is_active FROM pitch_admins WHERE email = $1`, + [email], + ) + + if (rows.length === 0) { + await logAdminAudit(null, 'admin_login_failed', { email, reason: 'unknown_email' }, request) + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const admin = rows[0] + + if (!admin.is_active) { + await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'inactive' }, request) + return NextResponse.json({ error: 'Account disabled' }, { status: 403 }) + } + + const ok = await verifyPassword(password, admin.password_hash) + if (!ok) { + await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'wrong_password' }, request) + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const ua = request.headers.get('user-agent') + const { jwt } = await createAdminSession(admin.id, ip, ua) + await setAdminCookie(jwt) + + await pool.query( + `UPDATE pitch_admins SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`, + [admin.id], + ) + + await logAdminAudit(admin.id, 'admin_login_success', { email }, request) + + return NextResponse.json({ + success: true, + admin: { id: admin.id, email: admin.email, name: admin.name }, + }) +} diff --git a/pitch-deck/app/api/admin-auth/logout/route.ts b/pitch-deck/app/api/admin-auth/logout/route.ts new file mode 100644 index 0000000..d990fc7 --- /dev/null +++ b/pitch-deck/app/api/admin-auth/logout/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + getAdminPayloadFromCookie, + revokeAdminSession, + clearAdminCookie, + logAdminAudit, +} from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + const payload = await getAdminPayloadFromCookie() + if (payload) { + await revokeAdminSession(payload.sessionId) + await logAdminAudit(payload.sub, 'admin_logout', {}, request) + } + await clearAdminCookie() + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/admin-auth/me/route.ts b/pitch-deck/app/api/admin-auth/me/route.ts new file mode 100644 index 0000000..a202fdf --- /dev/null +++ b/pitch-deck/app/api/admin-auth/me/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server' +import { getAdminFromCookie } from '@/lib/admin-auth' + +export async function GET() { + const admin = await getAdminFromCookie() + if (!admin) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + return NextResponse.json({ admin }) +} diff --git a/pitch-deck/app/api/admin/admins/[id]/route.ts b/pitch-deck/app/api/admin/admins/[id]/route.ts new file mode 100644 index 0000000..ca7daef --- /dev/null +++ b/pitch-deck/app/api/admin/admins/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, logAdminAudit, hashPassword, revokeAllAdminSessions } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function PATCH(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const actorAdminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + const body = await request.json().catch(() => ({})) + const { name, is_active, password } = body + + const before = await pool.query( + `SELECT email, name, is_active FROM pitch_admins WHERE id = $1`, + [id], + ) + if (before.rows.length === 0) { + return NextResponse.json({ error: 'Admin not found' }, { status: 404 }) + } + + const updates: string[] = [] + const params: unknown[] = [] + let p = 1 + + if (typeof name === 'string' && name.trim()) { + updates.push(`name = $${p++}`) + params.push(name.trim()) + } + if (typeof is_active === 'boolean') { + updates.push(`is_active = $${p++}`) + params.push(is_active) + } + if (typeof password === 'string') { + if (password.length < 12) { + return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 }) + } + const hash = await hashPassword(password) + updates.push(`password_hash = $${p++}`) + params.push(hash) + } + + if (updates.length === 0) { + return NextResponse.json({ error: 'no fields to update' }, { status: 400 }) + } + + updates.push(`updated_at = NOW()`) + params.push(id) + + const { rows } = await pool.query( + `UPDATE pitch_admins SET ${updates.join(', ')} + WHERE id = $${p} + RETURNING id, email, name, is_active, last_login_at, created_at`, + params, + ) + + // If deactivated or password changed, revoke their sessions + if (is_active === false || typeof password === 'string') { + await revokeAllAdminSessions(id) + } + + const action = is_active === false ? 'admin_deactivated' : 'admin_edited' + await logAdminAudit( + actorAdminId, + action, + { + target_admin_id: id, + target_email: before.rows[0].email, + before: before.rows[0], + after: { name: rows[0].name, is_active: rows[0].is_active }, + password_changed: typeof password === 'string', + }, + request, + ) + + return NextResponse.json({ admin: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/admins/route.ts b/pitch-deck/app/api/admin/admins/route.ts new file mode 100644 index 0000000..dc01044 --- /dev/null +++ b/pitch-deck/app/api/admin/admins/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, logAdminAudit, hashPassword } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { rows } = await pool.query( + `SELECT id, email, name, is_active, last_login_at, created_at, updated_at + FROM pitch_admins ORDER BY created_at ASC`, + ) + return NextResponse.json({ admins: rows }) +} + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const body = await request.json().catch(() => ({})) + const email = (body.email || '').trim().toLowerCase() + const name = (body.name || '').trim() + const password = body.password || '' + + if (!email || !name || !password) { + return NextResponse.json({ error: 'email, name, password required' }, { status: 400 }) + } + if (password.length < 12) { + return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 }) + } + + const hash = await hashPassword(password) + + try { + const { rows } = await pool.query( + `INSERT INTO pitch_admins (email, name, password_hash, is_active) + VALUES ($1, $2, $3, true) + RETURNING id, email, name, is_active, created_at`, + [email, name, hash], + ) + const newAdmin = rows[0] + await logAdminAudit(adminId, 'admin_created', { email, name, new_admin_id: newAdmin.id }, request) + return NextResponse.json({ admin: newAdmin }) + } catch (err) { + const e = err as { code?: string } + if (e.code === '23505') { + return NextResponse.json({ error: 'Email already exists' }, { status: 409 }) + } + throw err + } +} diff --git a/pitch-deck/app/api/admin/audit-logs/route.ts b/pitch-deck/app/api/admin/audit-logs/route.ts new file mode 100644 index 0000000..74509d1 --- /dev/null +++ b/pitch-deck/app/api/admin/audit-logs/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { searchParams } = new URL(request.url) + const investorId = searchParams.get('investor_id') + const targetInvestorId = searchParams.get('target_investor_id') + const adminId = searchParams.get('admin_id') + const actorType = searchParams.get('actor_type') // 'admin' | 'investor' + const action = searchParams.get('action') + const since = searchParams.get('since') // ISO date + const until = searchParams.get('until') + const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500) + const offset = parseInt(searchParams.get('offset') || '0') + + const conditions: string[] = [] + const params: unknown[] = [] + let p = 1 + + if (investorId) { + conditions.push(`a.investor_id = $${p++}`) + params.push(investorId) + } + if (targetInvestorId) { + conditions.push(`a.target_investor_id = $${p++}`) + params.push(targetInvestorId) + } + if (adminId) { + conditions.push(`a.admin_id = $${p++}`) + params.push(adminId) + } + if (actorType === 'admin') { + conditions.push(`a.admin_id IS NOT NULL`) + } else if (actorType === 'investor') { + conditions.push(`a.investor_id IS NOT NULL`) + } + if (action) { + conditions.push(`a.action = $${p++}`) + params.push(action) + } + if (since) { + conditions.push(`a.created_at >= $${p++}`) + params.push(since) + } + if (until) { + conditions.push(`a.created_at <= $${p++}`) + params.push(until) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const { rows } = await pool.query( + `SELECT a.*, + i.email AS investor_email, i.name AS investor_name, + ti.email AS target_investor_email, ti.name AS target_investor_name, + ad.email AS admin_email, ad.name AS admin_name + FROM pitch_audit_logs a + LEFT JOIN pitch_investors i ON i.id = a.investor_id + LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id + LEFT JOIN pitch_admins ad ON ad.id = a.admin_id + ${where} + ORDER BY a.created_at DESC + LIMIT $${p++} OFFSET $${p++}`, + [...params, limit, offset], + ) + + const totalRes = await pool.query( + `SELECT COUNT(*)::int AS total FROM pitch_audit_logs a ${where}`, + params, + ) + + return NextResponse.json({ logs: rows, total: totalRes.rows[0].total }) +} diff --git a/pitch-deck/app/api/admin/dashboard/route.ts b/pitch-deck/app/api/admin/dashboard/route.ts new file mode 100644 index 0000000..8104ab7 --- /dev/null +++ b/pitch-deck/app/api/admin/dashboard/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const [totals, recentLogins, recentActivity] = await Promise.all([ + pool.query(` + SELECT + (SELECT COUNT(*)::int FROM pitch_investors) AS total_investors, + (SELECT COUNT(*)::int FROM pitch_investors WHERE status = 'invited') AS pending_invites, + (SELECT COUNT(*)::int FROM pitch_investors WHERE last_login_at >= NOW() - INTERVAL '7 days') AS active_7d, + (SELECT COUNT(*)::int FROM pitch_audit_logs WHERE action = 'slide_viewed') AS slides_viewed_total, + (SELECT COUNT(*)::int FROM pitch_sessions WHERE revoked = false AND expires_at > NOW()) AS active_sessions, + (SELECT COUNT(*)::int FROM pitch_admins WHERE is_active = true) AS active_admins + `), + pool.query(` + SELECT a.created_at, a.ip_address, i.id AS investor_id, i.email, i.name, i.company + FROM pitch_audit_logs a + JOIN pitch_investors i ON i.id = a.investor_id + WHERE a.action = 'login_success' + ORDER BY a.created_at DESC + LIMIT 10 + `), + pool.query(` + SELECT a.id, a.action, a.created_at, a.details, + i.email AS investor_email, i.name AS investor_name, + ti.email AS target_investor_email, + ad.email AS admin_email, ad.name AS admin_name + FROM pitch_audit_logs a + LEFT JOIN pitch_investors i ON i.id = a.investor_id + LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id + LEFT JOIN pitch_admins ad ON ad.id = a.admin_id + ORDER BY a.created_at DESC + LIMIT 15 + `), + ]) + + return NextResponse.json({ + totals: totals.rows[0], + recent_logins: recentLogins.rows, + recent_activity: recentActivity.rows, + }) +} diff --git a/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts b/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts new file mode 100644 index 0000000..bb531c2 --- /dev/null +++ b/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function PATCH(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + const body = await request.json().catch(() => ({})) + const { value, min_value, max_value, step_size, label_de, label_en } = body + + const before = await pool.query( + `SELECT scenario_id, key, label_de, label_en, value, min_value, max_value, step_size + FROM pitch_fm_assumptions WHERE id = $1`, + [id], + ) + if (before.rows.length === 0) { + return NextResponse.json({ error: 'Assumption not found' }, { status: 404 }) + } + + const updates: string[] = [] + const params: unknown[] = [] + let p = 1 + + if (value !== undefined) { + updates.push(`value = $${p++}`) + params.push(JSON.stringify(value)) + } + if (min_value !== undefined) { + updates.push(`min_value = $${p++}`) + params.push(min_value) + } + if (max_value !== undefined) { + updates.push(`max_value = $${p++}`) + params.push(max_value) + } + if (step_size !== undefined) { + updates.push(`step_size = $${p++}`) + params.push(step_size) + } + if (typeof label_de === 'string') { + updates.push(`label_de = $${p++}`) + params.push(label_de) + } + if (typeof label_en === 'string') { + updates.push(`label_en = $${p++}`) + params.push(label_en) + } + + if (updates.length === 0) { + return NextResponse.json({ error: 'no fields to update' }, { status: 400 }) + } + + params.push(id) + const { rows } = await pool.query( + `UPDATE pitch_fm_assumptions SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`, + params, + ) + + // Invalidate cached results for this scenario so the next compute uses the new value + await pool.query(`DELETE FROM pitch_fm_results WHERE scenario_id = $1`, [before.rows[0].scenario_id]) + + await logAdminAudit( + adminId, + 'assumption_edited', + { + assumption_id: id, + scenario_id: before.rows[0].scenario_id, + key: before.rows[0].key, + before: { + value: typeof before.rows[0].value === 'string' ? JSON.parse(before.rows[0].value) : before.rows[0].value, + min_value: before.rows[0].min_value, + max_value: before.rows[0].max_value, + step_size: before.rows[0].step_size, + }, + after: { + value: typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value, + min_value: rows[0].min_value, + max_value: rows[0].max_value, + step_size: rows[0].step_size, + }, + }, + request, + ) + + return NextResponse.json({ assumption: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts b/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts new file mode 100644 index 0000000..efa4af2 --- /dev/null +++ b/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function PATCH(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + const body = await request.json().catch(() => ({})) + const { name, description, color } = body + + if (name === undefined && description === undefined && color === undefined) { + return NextResponse.json({ error: 'name, description, or color required' }, { status: 400 }) + } + + const before = await pool.query( + `SELECT name, description, color FROM pitch_fm_scenarios WHERE id = $1`, + [id], + ) + if (before.rows.length === 0) { + return NextResponse.json({ error: 'Scenario not found' }, { status: 404 }) + } + + const { rows } = await pool.query( + `UPDATE pitch_fm_scenarios SET + name = COALESCE($1, name), + description = COALESCE($2, description), + color = COALESCE($3, color) + WHERE id = $4 + RETURNING *`, + [name ?? null, description ?? null, color ?? null, id], + ) + + await logAdminAudit( + adminId, + 'scenario_edited', + { + scenario_id: id, + before: before.rows[0], + after: { name: rows[0].name, description: rows[0].description, color: rows[0].color }, + }, + request, + ) + + return NextResponse.json({ scenario: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/fm/scenarios/route.ts b/pitch-deck/app/api/admin/fm/scenarios/route.ts new file mode 100644 index 0000000..ec9b65a --- /dev/null +++ b/pitch-deck/app/api/admin/fm/scenarios/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const scenarios = await pool.query( + `SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name`, + ) + const assumptions = await pool.query( + `SELECT * FROM pitch_fm_assumptions ORDER BY scenario_id, sort_order`, + ) + + const result = scenarios.rows.map(s => ({ + ...s, + assumptions: assumptions.rows + .filter(a => a.scenario_id === s.id) + .map(a => ({ + ...a, + value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value, + })), + })) + + return NextResponse.json({ scenarios: result }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/resend/route.ts b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts new file mode 100644 index 0000000..6156ab7 --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { generateToken } from '@/lib/auth' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' +import { sendMagicLinkEmail } from '@/lib/email' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function POST(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + + const { rows } = await pool.query( + `SELECT id, email, name, status FROM pitch_investors WHERE id = $1`, + [id], + ) + if (rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + const investor = rows[0] + if (investor.status === 'revoked') { + return NextResponse.json({ error: 'Investor is revoked. Reactivate first by re-inviting.' }, { status: 400 }) + } + + // Rate limit by email + const rl = checkRateLimit(`magic-link:${investor.email}`, RATE_LIMITS.magicLink) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many resends for this email. Try again later.' }, { status: 429 }) + } + + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt], + ) + + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` + await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl) + + await logAdminAudit( + adminId, + 'magic_link_resent', + { email: investor.email, expires_at: expiresAt.toISOString() }, + request, + investor.id, + ) + + return NextResponse.json({ success: true, expires_at: expiresAt.toISOString() }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts new file mode 100644 index 0000000..ad447cd --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function GET(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { id } = await ctx.params + + const [investor, sessions, snapshots, audit] = await Promise.all([ + pool.query( + `SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at + FROM pitch_investors WHERE id = $1`, + [id], + ), + pool.query( + `SELECT id, ip_address, user_agent, expires_at, revoked, created_at + FROM pitch_sessions WHERE investor_id = $1 + ORDER BY created_at DESC LIMIT 50`, + [id], + ), + pool.query( + `SELECT id, scenario_id, label, is_latest, created_at + FROM pitch_investor_snapshots WHERE investor_id = $1 + ORDER BY created_at DESC LIMIT 50`, + [id], + ), + pool.query( + `SELECT a.id, a.action, a.created_at, a.details, a.ip_address, a.slide_id, + ad.email AS admin_email, ad.name AS admin_name + FROM pitch_audit_logs a + LEFT JOIN pitch_admins ad ON ad.id = a.admin_id + WHERE a.investor_id = $1 OR a.target_investor_id = $1 + ORDER BY a.created_at DESC LIMIT 100`, + [id], + ), + ]) + + if (investor.rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + return NextResponse.json({ + investor: investor.rows[0], + sessions: sessions.rows, + snapshots: snapshots.rows, + audit: audit.rows, + }) +} + +export async function PATCH(request: NextRequest, ctx: RouteContext) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const { id } = await ctx.params + const body = await request.json().catch(() => ({})) + const { name, company } = body + + if (name === undefined && company === undefined) { + return NextResponse.json({ error: 'name or company required' }, { status: 400 }) + } + + const before = await pool.query( + `SELECT name, company FROM pitch_investors WHERE id = $1`, + [id], + ) + if (before.rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + const { rows } = await pool.query( + `UPDATE pitch_investors SET + name = COALESCE($1, name), + company = COALESCE($2, company), + updated_at = NOW() + WHERE id = $3 + RETURNING id, email, name, company, status`, + [name ?? null, company ?? null, id], + ) + + await logAdminAudit( + adminId, + 'investor_edited', + { + before: before.rows[0], + after: { name: rows[0].name, company: rows[0].company }, + }, + request, + id, + ) + + return NextResponse.json({ investor: rows[0] }) +} diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts new file mode 100644 index 0000000..712eff0 --- /dev/null +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { requireAdmin } from '@/lib/admin-auth' + +export async function GET(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const { rows } = await pool.query( + `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, + (SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, + (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity + FROM pitch_investors i + ORDER BY i.created_at DESC`, + ) + + return NextResponse.json({ investors: rows }) +} diff --git a/pitch-deck/app/api/admin/invite/route.ts b/pitch-deck/app/api/admin/invite/route.ts new file mode 100644 index 0000000..602026e --- /dev/null +++ b/pitch-deck/app/api/admin/invite/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { generateToken } from '@/lib/auth' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' +import { sendMagicLinkEmail } from '@/lib/email' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const body = await request.json().catch(() => ({})) + const { email, name, company } = body + + if (!email || typeof email !== 'string') { + return NextResponse.json({ error: 'Email required' }, { status: 400 }) + } + + // Rate limit by email (3/hour) + const rl = checkRateLimit(`magic-link:${email.toLowerCase()}`, RATE_LIMITS.magicLink) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many invites for this email. Try again later.' }, { status: 429 }) + } + + const normalizedEmail = email.toLowerCase().trim() + + // Upsert investor + const { rows } = await pool.query( + `INSERT INTO pitch_investors (email, name, company) + VALUES ($1, $2, $3) + ON CONFLICT (email) DO UPDATE SET + name = COALESCE(EXCLUDED.name, pitch_investors.name), + company = COALESCE(EXCLUDED.company, pitch_investors.company), + status = CASE WHEN pitch_investors.status = 'revoked' THEN 'invited' ELSE pitch_investors.status END, + updated_at = NOW() + RETURNING id, status`, + [normalizedEmail, name || null, company || null], + ) + + const investor = rows[0] + + // Generate magic link + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) + VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt], + ) + + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` + + await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl) + + await logAdminAudit( + adminId, + 'investor_invited', + { email: normalizedEmail, name: name || null, company: company || null, expires_at: expiresAt.toISOString() }, + request, + investor.id, + ) + + return NextResponse.json({ + success: true, + investor_id: investor.id, + email: normalizedEmail, + expires_at: expiresAt.toISOString(), + }) +} diff --git a/pitch-deck/app/api/admin/revoke/route.ts b/pitch-deck/app/api/admin/revoke/route.ts new file mode 100644 index 0000000..cf0e6b5 --- /dev/null +++ b/pitch-deck/app/api/admin/revoke/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { revokeAllSessions } from '@/lib/auth' +import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const adminId = guard.kind === 'admin' ? guard.admin.id : null + + const body = await request.json().catch(() => ({})) + const { investor_id } = body + + if (!investor_id) { + return NextResponse.json({ error: 'investor_id required' }, { status: 400 }) + } + + const { rows } = await pool.query( + `UPDATE pitch_investors SET status = 'revoked', updated_at = NOW() + WHERE id = $1 RETURNING email`, + [investor_id], + ) + + if (rows.length === 0) { + return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) + } + + await revokeAllSessions(investor_id) + await logAdminAudit(adminId, 'investor_revoked', { email: rows[0].email }, request, investor_id) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/audit/route.ts b/pitch-deck/app/api/audit/route.ts new file mode 100644 index 0000000..e8c0a75 --- /dev/null +++ b/pitch-deck/app/api/audit/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionFromCookie, logAudit } from '@/lib/auth' + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const body = await request.json() + const { action, details, slide_id } = body + + if (!action || typeof action !== 'string') { + return NextResponse.json({ error: 'action required' }, { status: 400 }) + } + + // Only allow known client-side actions + const allowedActions = ['slide_viewed', 'assumption_changed', 'chat_message_sent', 'snapshot_saved', 'snapshot_restored'] + if (!allowedActions.includes(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } + + await logAudit(session.sub, action, details || {}, request, slide_id, session.sessionId) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/auth/logout/route.ts b/pitch-deck/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f95713b --- /dev/null +++ b/pitch-deck/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionFromCookie, revokeSession, clearSessionCookie, logAudit } from '@/lib/auth' + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + + if (session) { + await revokeSession(session.sessionId) + await logAudit(session.sub, 'logout', {}, request, undefined, session.sessionId) + } + + await clearSessionCookie() + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/auth/me/route.ts b/pitch-deck/app/api/auth/me/route.ts new file mode 100644 index 0000000..5d053d8 --- /dev/null +++ b/pitch-deck/app/api/auth/me/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie, validateSession } from '@/lib/auth' + +export async function GET() { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const valid = await validateSession(session.sessionId, session.sub) + if (!valid) { + return NextResponse.json({ error: 'Session expired' }, { status: 401 }) + } + + const { rows } = await pool.query( + `SELECT id, email, name, company, status, last_login_at, login_count, created_at + FROM pitch_investors WHERE id = $1`, + [session.sub] + ) + + if (rows.length === 0 || rows[0].status === 'revoked') { + return NextResponse.json({ error: 'Access revoked' }, { status: 403 }) + } + + return NextResponse.json({ investor: rows[0] }) +} diff --git a/pitch-deck/app/api/auth/verify/route.ts b/pitch-deck/app/api/auth/verify/route.ts new file mode 100644 index 0000000..2b29e6c --- /dev/null +++ b/pitch-deck/app/api/auth/verify/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { createSession, setSessionCookie, getClientIp, logAudit, hashToken } from '@/lib/auth' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + const ip = getClientIp(request) || 'unknown' + + // Rate limit by IP + const rl = checkRateLimit(`auth-verify:${ip}`, RATE_LIMITS.authVerify) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }) + } + + const body = await request.json() + const { token } = body + + if (!token || typeof token !== 'string') { + return NextResponse.json({ error: 'Token required' }, { status: 400 }) + } + + // Find the magic link + const { rows } = await pool.query( + `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status + FROM pitch_magic_links ml + JOIN pitch_investors i ON i.id = ml.investor_id + WHERE ml.token = $1`, + [token] + ) + + if (rows.length === 0) { + await logAudit(null, 'login_failed', { reason: 'invalid_token' }, request) + return NextResponse.json({ error: 'Invalid link' }, { status: 401 }) + } + + const link = rows[0] + + if (link.used_at) { + await logAudit(link.investor_id, 'login_failed', { reason: 'token_already_used' }, request) + return NextResponse.json({ error: 'This link has already been used. Please request a new one.' }, { status: 401 }) + } + + if (new Date(link.expires_at) < new Date()) { + await logAudit(link.investor_id, 'login_failed', { reason: 'token_expired' }, request) + return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 }) + } + + if (link.investor_status === 'revoked') { + await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) + return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) + } + + const ua = request.headers.get('user-agent') + + // Mark token as used + await pool.query( + `UPDATE pitch_magic_links SET used_at = NOW(), ip_address = $1, user_agent = $2 WHERE id = $3`, + [ip, ua, link.id] + ) + + // Activate investor if first login + await pool.query( + `UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW() + WHERE id = $1`, + [link.investor_id] + ) + + // Create session and set cookie + const { sessionId, jwt } = await createSession(link.investor_id, ip, ua) + await setSessionCookie(jwt) + + // Check for new IP + const { rows: prevSessions } = await pool.query( + `SELECT DISTINCT ip_address FROM pitch_sessions WHERE investor_id = $1 AND id != $2 AND ip_address IS NOT NULL`, + [link.investor_id, sessionId] + ) + const knownIps = prevSessions.map((r: { ip_address: string }) => r.ip_address) + if (knownIps.length > 0 && !knownIps.includes(ip)) { + await logAudit(link.investor_id, 'new_ip_detected', { ip, known_ips: knownIps }, request, undefined, sessionId) + } + + await logAudit(link.investor_id, 'login_success', { email: link.email }, request, undefined, sessionId) + + return NextResponse.json({ success: true, redirect: '/' }) +} diff --git a/pitch-deck/app/api/health/route.ts b/pitch-deck/app/api/health/route.ts new file mode 100644 index 0000000..dacd456 --- /dev/null +++ b/pitch-deck/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }) +} diff --git a/pitch-deck/app/api/snapshots/route.ts b/pitch-deck/app/api/snapshots/route.ts new file mode 100644 index 0000000..d5c2798 --- /dev/null +++ b/pitch-deck/app/api/snapshots/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' + +export async function GET() { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { rows } = await pool.query( + `SELECT id, scenario_id, assumptions, label, is_latest, created_at + FROM pitch_investor_snapshots + WHERE investor_id = $1 AND is_latest = true + ORDER BY created_at DESC`, + [session.sub] + ) + + return NextResponse.json({ snapshots: rows }) +} + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const body = await request.json() + const { scenario_id, assumptions, label } = body + + if (!scenario_id || !assumptions) { + return NextResponse.json({ error: 'scenario_id and assumptions required' }, { status: 400 }) + } + + // Mark previous latest as not latest + await pool.query( + `UPDATE pitch_investor_snapshots SET is_latest = false + WHERE investor_id = $1 AND scenario_id = $2 AND is_latest = true`, + [session.sub, scenario_id] + ) + + // Insert new snapshot + const { rows } = await pool.query( + `INSERT INTO pitch_investor_snapshots (investor_id, scenario_id, assumptions, label, is_latest) + VALUES ($1, $2, $3, $4, true) + RETURNING id, created_at`, + [session.sub, scenario_id, JSON.stringify(assumptions), label || null] + ) + + return NextResponse.json({ snapshot: rows[0] }) +} + +export async function DELETE(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'Snapshot id required' }, { status: 400 }) + } + + await pool.query( + `DELETE FROM pitch_investor_snapshots WHERE id = $1 AND investor_id = $2`, + [id, session.sub] + ) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/auth/page.tsx b/pitch-deck/app/auth/page.tsx new file mode 100644 index 0000000..a9d4053 --- /dev/null +++ b/pitch-deck/app/auth/page.tsx @@ -0,0 +1,57 @@ +'use client' + +import { motion } from 'framer-motion' + +export default function AuthPage() { + return ( +
Investor Pitch Deck
++ This interactive pitch deck is available by invitation only. + Please check your email for an access link. +
+ ++ Questions? Contact us at{' '} + + pitch@breakpilot.ai + +
++ We are an AI-first company. No PDFs. No slide decks. Just code. +
+Verifying your access link...
+ > + )} + + {status === 'success' && ( + <> +Access verified!
+Redirecting to pitch deck...
+ > + )} + + {status === 'error' && ( + <> +Access Denied
+{errorMsg}
+ + Back to login + + > + )} +Loading...
+