feat(pitch-deck): self-service magic-link reissue on /auth
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
Investors who lost their session or whose invite token was already used can now enter their email on /auth to receive a fresh access link, without needing a manual re-invite from an admin. - New /api/auth/request-link endpoint looks up the investor by email, issues a new pitch_magic_links row, and emails the link via the existing sendMagicLinkEmail path. Response is generic regardless of whether the email exists (enumeration resistance) and silently no-ops for revoked investors. - Rate-limited both per-IP (authVerify preset) and per-email (magicLink preset, 3/hour — same ceiling as admin-invite/resend). - /auth page now renders an email form; submits to the new endpoint and shows a generic "if invited, link sent" confirmation. - Route-level tests cover validation, normalization, unknown email, revoked investor, and both rate-limit paths. - End-to-end regression test wires request-link + verify against an in-memory fake DB and asserts the full flow: original invite used → replay rejected → email submission → fresh link → verify succeeds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken, getClientIp, logAudit } from '@/lib/auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
// Generic response returned regardless of whether an investor exists, to
|
||||
// prevent email enumeration. The client always sees the same success message.
|
||||
const GENERIC_RESPONSE = {
|
||||
success: true,
|
||||
message: 'If this email was invited, a fresh access link has been sent.',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// IP-based rate limit to prevent enumeration / abuse
|
||||
const ipRl = checkRateLimit(`request-link-ip:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!ipRl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { email } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Per-email rate limit (silent — same generic response on throttle so callers
|
||||
// can't distinguish a throttled-but-valid email from an unknown one)
|
||||
const emailRl = checkRateLimit(`magic-link:${normalizedEmail}`, RATE_LIMITS.magicLink)
|
||||
if (!emailRl.allowed) {
|
||||
await logAudit(null, 'request_link_throttled', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, status FROM pitch_investors WHERE email = $1`,
|
||||
[normalizedEmail],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAudit(null, 'request_link_unknown_email', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
const investor = rows[0]
|
||||
|
||||
if (investor.status === 'revoked') {
|
||||
await logAudit(investor.id, 'request_link_revoked', { email: normalizedEmail }, request)
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
|
||||
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 logAudit(investor.id, 'request_link_sent', { email: normalizedEmail, expires_at: expiresAt.toISOString() }, request)
|
||||
|
||||
return NextResponse.json(GENERIC_RESPONSE)
|
||||
}
|
||||
Reference in New Issue
Block a user