feat(pitch-deck): add passwordless investor auth, audit logs, snapshots & PWA

Implement a complete investor access system for the pitch deck:

- Passwordless magic link auth (jose JWT + nodemailer SMTP)
- Per-investor audit logging (slide views, assumption changes, chat)
- Financial model snapshot persistence (auto-save/restore per investor)
- PWA support (manifest, service worker, offline caching, icons)
- Security safeguards (watermark overlay, rate limiting, anti-scraping
  headers, content protection, single-session enforcement)
- Admin API for invite/revoke/audit-log management
- Integrated into docker-compose.coolify.yml for production deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-05 09:37:50 +02:00
parent 3a2567b44d
commit 244071d63b
35 changed files with 4232 additions and 14 deletions

View File

@@ -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 })
}

View File

@@ -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] })
}

View File

@@ -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: '/' })
}