feat(pitch-deck): add passwordless investor auth, audit logs, snapshots & PWA
Some checks failed
CI / go-lint (pull_request) Failing after 17s
CI / python-lint (pull_request) Failing after 12s
CI / nodejs-lint (pull_request) Failing after 7s
CI / test-go-consent (pull_request) Failing after 11s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
Some checks failed
CI / go-lint (pull_request) Failing after 17s
CI / python-lint (pull_request) Failing after 12s
CI / nodejs-lint (pull_request) Failing after 7s
CI / test-go-consent (pull_request) Failing after 11s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
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:
160
pitch-deck/lib/auth.ts
Normal file
160
pitch-deck/lib/auth.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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
|
||||
): 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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId]
|
||||
)
|
||||
}
|
||||
91
pitch-deck/lib/email.ts
Normal file
91
pitch-deck/lib/email.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth: {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
const fromName = process.env.SMTP_FROM_NAME || 'BreakPilot'
|
||||
const fromAddr = process.env.SMTP_FROM_ADDR || 'noreply@breakpilot.ai'
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
investorName: string | null,
|
||||
magicLinkUrl: string
|
||||
): Promise<void> {
|
||||
const greeting = investorName ? `Hello ${investorName}` : 'Hello'
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromAddr}>`,
|
||||
to,
|
||||
subject: 'Your BreakPilot Pitch Deck Access',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0a0a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a1a;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#111127;border-radius:12px;border:1px solid rgba(99,102,241,0.2);">
|
||||
<tr>
|
||||
<td style="padding:40px 40px 20px;">
|
||||
<h1 style="margin:0;font-size:24px;color:#e0e0ff;font-weight:600;">
|
||||
BreakPilot ComplAI
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
|
||||
Investor Pitch Deck
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
${greeting},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
You have been invited to view the BreakPilot ComplAI investor pitch deck.
|
||||
Click the button below to access the interactive presentation.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:8px;padding:14px 32px;">
|
||||
<a href="${magicLinkUrl}" style="color:#ffffff;font-size:16px;font-weight:600;text-decoration:none;display:inline-block;">
|
||||
View Pitch Deck
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.5;">
|
||||
This link expires in ${process.env.MAGIC_LINK_TTL_HOURS || '72'} hours and can only be used once.
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.3);line-height:1.5;word-break:break-all;">
|
||||
${magicLinkUrl}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px 40px;border-top:1px solid rgba(255,255,255,0.05);">
|
||||
<p style="margin:0;font-size:12px;color:rgba(255,255,255,0.25);line-height:1.5;">
|
||||
If you did not expect this email, you can safely ignore it.
|
||||
This is an AI-first company — we don't do PDFs.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
})
|
||||
}
|
||||
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface AuditTrackerOptions {
|
||||
investorId: string | null
|
||||
currentSlide: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function useAuditTracker({ investorId, currentSlide, enabled }: AuditTrackerOptions) {
|
||||
const lastSlide = useRef<string>('')
|
||||
const slideTimestamps = useRef<Map<string, number>>(new Map())
|
||||
const pendingEvents = useRef<Array<{ action: string; details: Record<string, unknown>; slide_id?: string }>>([])
|
||||
const flushTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (pendingEvents.current.length === 0) return
|
||||
const events = [...pendingEvents.current]
|
||||
pendingEvents.current = []
|
||||
|
||||
// Send events one at a time (they're debounced so there shouldn't be many)
|
||||
for (const event of events) {
|
||||
try {
|
||||
await fetch('/api/audit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail - audit should not block UX
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useCallback((action: string, details: Record<string, unknown> = {}, slideId?: string) => {
|
||||
if (!enabled || !investorId) return
|
||||
|
||||
pendingEvents.current.push({ action, details, slide_id: slideId })
|
||||
|
||||
// Debounce flush by 500ms
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flushTimer.current = setTimeout(flush, 500)
|
||||
}, [enabled, investorId, flush])
|
||||
|
||||
// Track slide views
|
||||
useEffect(() => {
|
||||
if (!enabled || !investorId || !currentSlide) return
|
||||
if (currentSlide === lastSlide.current) return
|
||||
|
||||
const now = Date.now()
|
||||
const prevTimestamp = slideTimestamps.current.get(lastSlide.current)
|
||||
const dwellTime = prevTimestamp ? now - prevTimestamp : 0
|
||||
|
||||
lastSlide.current = currentSlide
|
||||
slideTimestamps.current.set(currentSlide, now)
|
||||
|
||||
track('slide_viewed', {
|
||||
slide_id: currentSlide,
|
||||
previous_dwell_ms: dwellTime,
|
||||
}, currentSlide)
|
||||
}, [currentSlide, enabled, investorId, track])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimer.current) clearTimeout(flushTimer.current)
|
||||
flush()
|
||||
}
|
||||
}, [flush])
|
||||
|
||||
return { track }
|
||||
}
|
||||
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [investor, setInvestor] = useState<Investor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInvestor(data.investor)
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchMe()
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/auth'
|
||||
}, [])
|
||||
|
||||
return { investor, loading, logout }
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FMScenario, FMResult, FMComputeResponse } from '../types'
|
||||
import { FMScenario, FMResult, FMComputeResponse, InvestorSnapshot } from '../types'
|
||||
|
||||
export function useFinancialModel() {
|
||||
export function useFinancialModel(investorId?: string | null) {
|
||||
const [scenarios, setScenarios] = useState<FMScenario[]>([])
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [computing, setComputing] = useState(false)
|
||||
const [snapshotStatus, setSnapshotStatus] = useState<'default' | 'saving' | 'saved' | 'restored'>('default')
|
||||
const computeTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const snapshotsLoaded = useRef(false)
|
||||
|
||||
// Load scenarios on mount
|
||||
// Load scenarios on mount, then apply snapshots if investor is logged in
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
let data: FMScenario[] = await res.json()
|
||||
|
||||
// If investor is logged in, restore their snapshots
|
||||
if (investorId && !snapshotsLoaded.current) {
|
||||
try {
|
||||
const snapRes = await fetch('/api/snapshots')
|
||||
if (snapRes.ok) {
|
||||
const { snapshots } = await snapRes.json() as { snapshots: InvestorSnapshot[] }
|
||||
if (snapshots.length > 0) {
|
||||
data = data.map(scenario => {
|
||||
const snapshot = snapshots.find(s => s.scenario_id === scenario.id)
|
||||
if (!snapshot) return scenario
|
||||
return {
|
||||
...scenario,
|
||||
assumptions: scenario.assumptions.map(a => {
|
||||
const savedValue = snapshot.assumptions[a.key]
|
||||
return savedValue !== undefined ? { ...a, value: savedValue } : a
|
||||
}),
|
||||
}
|
||||
})
|
||||
setSnapshotStatus('restored')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Snapshot restore failed — use defaults
|
||||
}
|
||||
snapshotsLoaded.current = true
|
||||
}
|
||||
|
||||
setScenarios(data)
|
||||
const defaultScenario = data.find(s => s.is_default) || data[0]
|
||||
if (defaultScenario) {
|
||||
@@ -32,7 +63,7 @@ export function useFinancialModel() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
}, [investorId])
|
||||
|
||||
// Compute when active scenario changes
|
||||
useEffect(() => {
|
||||
@@ -60,6 +91,28 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-save snapshot (debounced)
|
||||
const saveSnapshot = useCallback(async (scenarioId: string) => {
|
||||
if (!investorId) return
|
||||
const scenario = scenarios.find(s => s.id === scenarioId)
|
||||
if (!scenario) return
|
||||
|
||||
const assumptions: Record<string, number | number[]> = {}
|
||||
scenario.assumptions.forEach(a => { assumptions[a.key] = a.value })
|
||||
|
||||
setSnapshotStatus('saving')
|
||||
try {
|
||||
await fetch('/api/snapshots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenario_id: scenarioId, assumptions }),
|
||||
})
|
||||
setSnapshotStatus('saved')
|
||||
} catch {
|
||||
setSnapshotStatus('default')
|
||||
}
|
||||
}, [investorId, scenarios])
|
||||
|
||||
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||
// Optimistic update in local state
|
||||
setScenarios(prev => prev.map(s => {
|
||||
@@ -80,7 +133,33 @@ export function useFinancialModel() {
|
||||
// Debounced recompute
|
||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
||||
}, [compute])
|
||||
|
||||
// Debounced snapshot save (2s after last change)
|
||||
if (snapshotTimer.current) clearTimeout(snapshotTimer.current)
|
||||
snapshotTimer.current = setTimeout(() => saveSnapshot(scenarioId), 2000)
|
||||
}, [compute, saveSnapshot])
|
||||
|
||||
const resetToDefaults = useCallback(async (scenarioId: string) => {
|
||||
// Reload from server (without snapshots)
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
const defaultScenario = data.find(s => s.id === scenarioId)
|
||||
if (defaultScenario) {
|
||||
setScenarios(prev => prev.map(s => s.id === scenarioId ? defaultScenario : s))
|
||||
// Delete snapshot
|
||||
if (investorId) {
|
||||
await fetch(`/api/snapshots?id=${scenarioId}`, { method: 'DELETE' })
|
||||
}
|
||||
setSnapshotStatus('default')
|
||||
compute(scenarioId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [compute, investorId])
|
||||
|
||||
const computeAll = useCallback(async () => {
|
||||
for (const s of scenarios) {
|
||||
@@ -105,5 +184,7 @@ export function useFinancialModel() {
|
||||
compute,
|
||||
computeAll,
|
||||
updateAssumption,
|
||||
resetToDefaults,
|
||||
snapshotStatus,
|
||||
}
|
||||
}
|
||||
|
||||
52
pitch-deck/lib/rate-limit.ts
Normal file
52
pitch-deck/lib/rate-limit.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Cleanup stale entries every 60 seconds
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) store.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/** Max requests in the window */
|
||||
limit: number
|
||||
/** Window size in seconds */
|
||||
windowSec: number
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string, config: RateLimitConfig): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
store.set(key, { count: 1, resetAt: now + config.windowSec * 1000 })
|
||||
return { allowed: true, remaining: config.limit - 1, resetAt: now + config.windowSec * 1000 }
|
||||
}
|
||||
|
||||
if (entry.count >= config.limit) {
|
||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return { allowed: true, remaining: config.limit - entry.count, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
// Preset configurations
|
||||
export const RATE_LIMITS = {
|
||||
magicLink: { limit: 3, windowSec: 3600 } as RateLimitConfig, // 3 per email per hour
|
||||
authVerify: { limit: 10, windowSec: 900 } as RateLimitConfig, // 10 per IP per 15min
|
||||
api: { limit: 60, windowSec: 60 } as RateLimitConfig, // 60 per session per minute
|
||||
chat: { limit: 20, windowSec: 60 } as RateLimitConfig, // 20 per session per minute
|
||||
} as const
|
||||
@@ -193,6 +193,27 @@ export interface FMComputeResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Investor Auth Types
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: 'invited' | 'active' | 'revoked'
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface InvestorSnapshot {
|
||||
id: string
|
||||
scenario_id: string
|
||||
assumptions: Record<string, number | number[]>
|
||||
label: string | null
|
||||
is_latest: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Language = 'de' | 'en'
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user