merge: gitea/main — resolve pitch-deck conflicts (accept theirs)
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-09 14:43:32 +02:00
67 changed files with 6533 additions and 273 deletions

View File

@@ -0,0 +1,206 @@
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<string> {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export async function createAdminJwt(payload: AdminJwtPayload): Promise<string> {
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<AdminJwtPayload | null> {
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<boolean> {
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<void> {
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId])
}
export async function revokeAllAdminSessions(adminId: string): Promise<void> {
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId])
}
export async function setAdminCookie(jwt: string): Promise<void> {
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<void> {
const cookieStore = await cookies()
cookieStore.delete(ADMIN_COOKIE_NAME)
}
export async function getAdminPayloadFromCookie(): Promise<AdminJwtPayload | null> {
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<Admin | null> {
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<AdminGuardResult> {
// 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<string, unknown> = {},
request?: Request,
targetInvestorId?: string | null,
): Promise<void> {
await logAudit(
null, // investor_id
action,
details,
request,
undefined, // slide_id
undefined, // session_id
adminId,
targetInvestorId ?? null,
)
}
export { ADMIN_COOKIE_NAME }

163
pitch-deck/lib/auth.ts Normal file
View File

@@ -0,0 +1,163 @@
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]
)
}

91
pitch-deck/lib/email.ts Normal file
View 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>
`,
})
}

View 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 }
}

View 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 }
}

View File

@@ -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(() => {
@@ -41,17 +72,17 @@ export function useFinancialModel() {
}
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
const compute = useCallback(async (scenarioId: string, source?: string) => {
const compute = useCallback(async (scenarioId: string) => {
setComputing(true)
try {
const res = await fetch('/api/financial-model/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenarioId, source }),
body: JSON.stringify({ scenarioId }),
})
if (res.ok) {
const data: FMComputeResponse = await res.json()
setResults(prev => new Map(prev).set(source === 'finanzplan' ? 'finanzplan' : scenarioId, data))
setResults(prev => new Map(prev).set(scenarioId, data))
}
} catch (err) {
console.error('Compute failed:', err)
@@ -60,9 +91,27 @@ export function useFinancialModel() {
}
}, [])
const computeFromFinanzplan = useCallback(async () => {
await compute('', 'finanzplan')
}, [compute])
// 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
@@ -84,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) {
@@ -94,7 +169,6 @@ export function useFinancialModel() {
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
const finanzplanResults = results.get('finanzplan') || null
return {
scenarios,
@@ -102,7 +176,6 @@ export function useFinancialModel() {
activeScenarioId,
setActiveScenarioId,
activeResults,
finanzplanResults,
results,
loading,
computing,
@@ -110,7 +183,8 @@ export function useFinancialModel() {
setCompareMode,
compute,
computeAll,
computeFromFinanzplan,
updateAssumption,
resetToDefaults,
snapshotStatus,
}
}

View 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

View File

@@ -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 {