Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m11s
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 32s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 31s

This commit is contained in:
Benjamin Admin
2026-04-15 18:00:45 +02:00
4 changed files with 651 additions and 1 deletions

View File

@@ -0,0 +1,294 @@
/**
* Regression test for the "lost access" scenario:
*
* 1. Admin invites investor A → token T1 is created and emailed.
* 2. Investor A opens the link successfully → T1 is marked used_at.
* 3. Investor A clears their session (or a redeploy drops cookies).
* 4. Investor A returns to / — redirected to /auth.
* 5. Without this feature, A is stuck: T1 is already used, expired, or the
* session is gone, and there is no self-service way to get back in.
* 6. With this feature, A enters their email on /auth and the endpoint
* issues a brand new, unused magic link T2 for the same investor row.
*
* This test wires together the request-link handler with the real verify
* handler against an in-memory fake of the two tables the flow touches
* (pitch_investors, pitch_magic_links) so we can assert end-to-end that a
* second link works after the first one was used.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
// ---- In-memory fake of the two tables touched by this flow ----
interface InvestorRow {
id: string
email: string
name: string | null
company: string | null
status: 'invited' | 'active' | 'revoked'
last_login_at: Date | null
login_count: number
}
interface MagicLinkRow {
id: string
investor_id: string
token: string
expires_at: Date
used_at: Date | null
ip_address: string | null
user_agent: string | null
}
const db = {
investors: [] as InvestorRow[],
magicLinks: [] as MagicLinkRow[],
sessions: [] as { id: string; investor_id: string; ip_address: string | null }[],
}
let idCounter = 0
const nextId = () => `row-${++idCounter}`
// A tiny query router: match the SQL fragment we care about, ignore the rest.
const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => {
const s = sql.replace(/\s+/g, ' ').trim()
// Investor lookup by email (used by request-link)
if (/SELECT id, email, name, status FROM pitch_investors WHERE email = \$1/i.test(s)) {
const row = db.investors.find(i => i.email === params[0])
return { rows: row ? [row] : [] }
}
// Insert magic link
if (/INSERT INTO pitch_magic_links \(investor_id, token, expires_at\)/i.test(s)) {
db.magicLinks.push({
id: nextId(),
investor_id: params[0] as string,
token: params[1] as string,
expires_at: params[2] as Date,
used_at: null,
ip_address: null,
user_agent: null,
})
return { rows: [] }
}
// Verify: magic link + investor JOIN lookup
if (/FROM pitch_magic_links ml JOIN pitch_investors i/i.test(s)) {
const link = db.magicLinks.find(ml => ml.token === params[0])
if (!link) return { rows: [] }
const inv = db.investors.find(i => i.id === link.investor_id)!
return {
rows: [{
id: link.id,
investor_id: link.investor_id,
expires_at: link.expires_at,
used_at: link.used_at,
email: inv.email,
investor_status: inv.status,
}],
}
}
// Mark magic link used
if (/UPDATE pitch_magic_links SET used_at = NOW/i.test(s)) {
const link = db.magicLinks.find(ml => ml.id === params[2])
if (link) {
link.used_at = new Date()
link.ip_address = params[0] as string | null
link.user_agent = params[1] as string | null
}
return { rows: [] }
}
// Activate investor
if (/UPDATE pitch_investors SET status = 'active'/i.test(s)) {
const inv = db.investors.find(i => i.id === params[0])
if (inv) {
inv.status = 'active'
inv.last_login_at = new Date()
inv.login_count += 1
}
return { rows: [] }
}
// createSession: revoke prior sessions (no-op in fake)
if (/UPDATE pitch_sessions SET revoked = true WHERE investor_id/i.test(s)) {
return { rows: [] }
}
// createSession: insert
if (/INSERT INTO pitch_sessions/i.test(s)) {
const id = nextId()
db.sessions.push({ id, investor_id: params[0] as string, ip_address: params[2] as string | null })
return { rows: [{ id }] }
}
// createSession: fetch investor email for JWT
if (/SELECT email FROM pitch_investors WHERE id = \$1/i.test(s)) {
const inv = db.investors.find(i => i.id === params[0])
return { rows: inv ? [{ email: inv.email }] : [] }
}
// new-ip detection query (verify route)
if (/SELECT DISTINCT ip_address FROM pitch_sessions/i.test(s)) {
return { rows: [] }
}
// Audit log insert — accept everything
if (/INSERT INTO pitch_audit_logs/i.test(s)) {
return { rows: [] }
}
throw new Error(`Unmocked query: ${s.slice(0, 120)}`)
})
vi.mock('@/lib/db', () => ({
default: { query: (...args: unknown[]) => queryMock(args[0] as string, args[1] as unknown[]) },
}))
// Capture emails instead of sending them
const sentEmails: Array<{ to: string; url: string }> = []
vi.mock('@/lib/email', () => ({
sendMagicLinkEmail: vi.fn(async (to: string, _name: string | null, url: string) => {
sentEmails.push({ to, url })
}),
}))
// next/headers cookies() needs to be stubbed — setSessionCookie calls it.
vi.mock('next/headers', () => ({
cookies: async () => ({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
// Import the handlers AFTER mocks are set up
import { POST as requestLink } from '@/app/api/auth/request-link/route'
import { POST as verifyLink } from '@/app/api/auth/verify/route'
function makeJsonRequest(url: string, body: unknown, ip = '203.0.113.1'): NextRequest {
return new NextRequest(url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify(body),
})
}
function extractToken(url: string): string {
const m = url.match(/token=([0-9a-f]+)/)
if (!m) throw new Error(`No token in url: ${url}`)
return m[1]
}
beforeEach(() => {
db.investors = []
db.magicLinks = []
db.sessions = []
sentEmails.length = 0
idCounter = 0
queryMock.mockClear()
})
describe('Regression: investor can re-request a working magic link after the first is consumed', () => {
it('full flow — invite → use → request-link → new link works', async () => {
// --- Setup: admin has already invited the investor (simulate the outcome) ---
const investorId = 'investor-42'
db.investors.push({
id: investorId,
email: 'vc@example.com',
name: 'VC Partner',
company: 'Acme Capital',
status: 'invited',
last_login_at: null,
login_count: 0,
})
db.magicLinks.push({
id: 'ml-original',
investor_id: investorId,
token: 'a'.repeat(96), // original invite token
expires_at: new Date(Date.now() + 72 * 60 * 60 * 1000),
used_at: null,
ip_address: null,
user_agent: null,
})
// --- Step 1: investor uses the original invite link ---
const firstVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
expect(firstVerify.status).toBe(200)
const first = db.magicLinks.find(ml => ml.id === 'ml-original')!
expect(first.used_at).not.toBeNull()
// --- Step 2: investor comes back later; clicks the same link → rejected ---
const replay = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) }))
expect(replay.status).toBe(401)
const replayBody = await replay.json()
expect(replayBody.error).toMatch(/already been used/i)
// --- Step 3: investor visits /auth and submits their email ---
const reissue = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'vc@example.com' }, '203.0.113.99'),
)
expect(reissue.status).toBe(200)
const reissueBody = await reissue.json()
expect(reissueBody.success).toBe(true)
// --- Step 4: a fresh email was dispatched to the investor ---
expect(sentEmails).toHaveLength(1)
expect(sentEmails[0].to).toBe('vc@example.com')
const newToken = extractToken(sentEmails[0].url)
expect(newToken).not.toBe('a'.repeat(96))
expect(newToken).toMatch(/^[0-9a-f]{96}$/)
// A second unused magic link row exists for the same investor
const links = db.magicLinks.filter(ml => ml.investor_id === investorId)
expect(links).toHaveLength(2)
const newLink = links.find(ml => ml.token === newToken)!
expect(newLink.used_at).toBeNull()
// --- Step 5: the new token validates successfully ---
const secondVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: newToken }))
expect(secondVerify.status).toBe(200)
const secondBody = await secondVerify.json()
expect(secondBody.success).toBe(true)
expect(secondBody.redirect).toBe('/')
// And the new link is now used, mirroring the one-time-use contract
expect(newLink.used_at).not.toBeNull()
})
it('unknown emails do not create magic links or send email (prevents enumeration & abuse)', async () => {
// No investors in the DB
const res = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'stranger@example.com' }),
)
expect(res.status).toBe(200)
const body = await res.json()
// Same generic message as the happy path
expect(body.success).toBe(true)
expect(body.message).toMatch(/if this email was invited/i)
expect(sentEmails).toHaveLength(0)
expect(db.magicLinks).toHaveLength(0)
})
it('revoked investors cannot self-serve a new link', async () => {
db.investors.push({
id: 'revoked-1',
email: 'gone@example.com',
name: null,
company: null,
status: 'revoked',
last_login_at: null,
login_count: 0,
})
const res = await requestLink(
makeJsonRequest('http://localhost/api/auth/request-link', { email: 'gone@example.com' }),
)
expect(res.status).toBe(200) // generic success (no info leak)
expect(sentEmails).toHaveLength(0)
expect(db.magicLinks).toHaveLength(0)
})
})

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
// Mock the DB pool before the route is imported
const queryMock = vi.fn()
vi.mock('@/lib/db', () => ({
default: { query: (...args: unknown[]) => queryMock(...args) },
}))
// Mock the email sender so no SMTP is attempted
const sendMagicLinkEmailMock = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/email', () => ({
sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args),
}))
// Import after mocks are registered
import { POST } from '@/app/api/auth/request-link/route'
// Unique suffix per test so the rate-limit store (keyed by IP / email) doesn't
// bleed across cases — the rate-limiter holds state at module scope.
let testId = 0
function uniqueIp() {
testId++
return `10.0.${Math.floor(testId / 250)}.${testId % 250}`
}
function makeRequest(body: unknown, ip = uniqueIp()): NextRequest {
return new NextRequest('http://localhost/api/auth/request-link', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-forwarded-for': ip,
},
body: JSON.stringify(body),
})
}
function investorRow(overrides: Partial<{ id: string; email: string; name: string | null; status: string }> = {}) {
return {
id: overrides.id ?? 'investor-1',
email: overrides.email ?? 'invited@example.com',
name: overrides.name ?? 'Alice',
status: overrides.status ?? 'invited',
}
}
beforeEach(() => {
queryMock.mockReset()
sendMagicLinkEmailMock.mockReset()
sendMagicLinkEmailMock.mockResolvedValue(undefined)
})
describe('POST /api/auth/request-link — input validation', () => {
it('returns 400 when email is missing', async () => {
const res = await POST(makeRequest({}))
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('Email required')
expect(queryMock).not.toHaveBeenCalled()
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
})
it('returns 400 when email is not a string', async () => {
const res = await POST(makeRequest({ email: 12345 }))
expect(res.status).toBe(400)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
})
it('handles malformed JSON body as missing email (400)', async () => {
const req = new NextRequest('http://localhost/api/auth/request-link', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': uniqueIp() },
body: 'not-json',
})
const res = await POST(req)
expect(res.status).toBe(400)
})
})
describe('POST /api/auth/request-link — unknown email (enumeration resistance)', () => {
it('returns the generic success response without sending email', async () => {
// First query: investor lookup → empty rows
queryMock.mockResolvedValueOnce({ rows: [] })
// Second query: the audit log insert
queryMock.mockResolvedValueOnce({ rows: [] })
const res = await POST(makeRequest({ email: 'unknown@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
expect(body.message).toMatch(/if this email was invited/i)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
// Verify the investor-lookup SQL was issued with the normalized email
const [sql, params] = queryMock.mock.calls[0]
expect(sql).toMatch(/FROM pitch_investors WHERE email/i)
expect(params).toEqual(['unknown@example.com'])
})
it('normalizes email (trim + lowercase) before lookup', async () => {
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: ' Mixed@Example.COM ' }))
const [, params] = queryMock.mock.calls[0]
expect(params).toEqual(['mixed@example.com'])
})
})
describe('POST /api/auth/request-link — known investor', () => {
it('creates a new magic link and sends the email with generic response', async () => {
// 1st: investor lookup → found
queryMock.mockResolvedValueOnce({ rows: [investorRow()] })
// 2nd: magic link insert
queryMock.mockResolvedValueOnce({ rows: [] })
// 3rd: audit log insert
queryMock.mockResolvedValueOnce({ rows: [] })
const res = await POST(makeRequest({ email: 'invited@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
// Response is identical to the unknown-email case (no information leak)
expect(body.message).toMatch(/if this email was invited/i)
// Verify magic link insert
const [insertSql, insertParams] = queryMock.mock.calls[1]
expect(insertSql).toMatch(/INSERT INTO pitch_magic_links/i)
expect(insertParams[0]).toBe('investor-1')
expect(insertParams[1]).toMatch(/^[0-9a-f]{96}$/) // 96-char hex token
expect(insertParams[2]).toBeInstanceOf(Date)
// Verify email was sent with the fresh token URL
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(1)
const [emailTo, emailName, magicLinkUrl] = sendMagicLinkEmailMock.mock.calls[0]
expect(emailTo).toBe('invited@example.com')
expect(emailName).toBe('Alice')
expect(magicLinkUrl).toMatch(/\/auth\/verify\?token=[0-9a-f]{96}$/)
})
it('generates a different token on each call (re-invite is always fresh)', async () => {
// Call 1
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'a@x.com' })] })
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: 'a@x.com' }))
// Call 2 — different email to avoid the per-email rate limit
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'b@x.com' })] })
queryMock.mockResolvedValueOnce({ rows: [] })
queryMock.mockResolvedValueOnce({ rows: [] })
await POST(makeRequest({ email: 'b@x.com' }))
const token1 = queryMock.mock.calls[1][1][1]
const token2 = queryMock.mock.calls[4][1][1]
expect(token1).not.toBe(token2)
})
it('skips email send for a revoked investor (returns generic response)', async () => {
queryMock.mockResolvedValueOnce({ rows: [investorRow({ status: 'revoked' })] })
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
const res = await POST(makeRequest({ email: 'invited@example.com' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
expect(sendMagicLinkEmailMock).not.toHaveBeenCalled()
// Ensure no magic link was inserted
const inserts = queryMock.mock.calls.filter(c => /INSERT INTO pitch_magic_links/i.test(c[0]))
expect(inserts.length).toBe(0)
})
})
describe('POST /api/auth/request-link — rate limiting', () => {
it('throttles after N requests per email and returns generic success (silent throttle)', async () => {
const email = `throttle-${Date.now()}@example.com`
// First 3 requests succeed (RATE_LIMITS.magicLink.limit = 3)
for (let i = 0; i < 3; i++) {
queryMock.mockResolvedValueOnce({ rows: [investorRow({ email })] })
queryMock.mockResolvedValueOnce({ rows: [] }) // magic link insert
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log
const res = await POST(makeRequest({ email }))
expect(res.status).toBe(200)
}
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
// 4th request is silently throttled — same generic response, no email sent
queryMock.mockResolvedValueOnce({ rows: [] }) // audit log only
const res4 = await POST(makeRequest({ email }))
expect(res4.status).toBe(200)
const body4 = await res4.json()
expect(body4.success).toBe(true)
// Still exactly 3 emails sent — nothing new
expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3)
})
it('throttles with 429 after too many attempts from the same IP', async () => {
const ip = '172.31.99.99'
// RATE_LIMITS.authVerify.limit = 10 for IP-scoped checks
for (let i = 0; i < 10; i++) {
queryMock.mockResolvedValueOnce({ rows: [] }) // investor lookup returns empty
queryMock.mockResolvedValueOnce({ rows: [] }) // audit
const res = await POST(makeRequest({ email: `ip-test-${i}@example.com` }, ip))
expect(res.status).toBe(200)
}
const res = await POST(makeRequest({ email: 'final@example.com' }, ip))
expect(res.status).toBe(429)
})
})

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

View File

@@ -1,8 +1,43 @@
'use client' 'use client'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useState, FormEvent } from 'react'
type Status = 'idle' | 'submitting' | 'sent' | 'error'
export default function AuthPage() { export default function AuthPage() {
const [email, setEmail] = useState('')
const [status, setStatus] = useState<Status>('idle')
const [message, setMessage] = useState<string | null>(null)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!email.trim()) return
setStatus('submitting')
setMessage(null)
try {
const res = await fetch('/api/auth/request-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() }),
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
setStatus('sent')
setMessage(data.message || 'If this email was invited, a fresh access link has been sent.')
} else {
setStatus('error')
setMessage(data.error || 'Something went wrong. Please try again.')
}
} catch {
setStatus('error')
setMessage('Network error. Please try again.')
}
}
return ( return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden"> <div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
{/* Background gradient */} {/* Background gradient */}
@@ -35,9 +70,44 @@ export default function AuthPage() {
<p className="text-white/50 text-sm leading-relaxed mb-6"> <p className="text-white/50 text-sm leading-relaxed mb-6">
This interactive pitch deck is available by invitation only. This interactive pitch deck is available by invitation only.
Please check your email for an access link. If you were invited, enter your email below and we&apos;ll send you a fresh access link.
</p> </p>
{status === 'sent' ? (
<div className="text-left bg-indigo-500/10 border border-indigo-500/20 rounded-lg p-4 mb-5">
<p className="text-indigo-200/90 text-sm leading-relaxed">
{message}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="text-left mb-5">
<label htmlFor="email" className="block text-white/60 text-xs mb-2 uppercase tracking-wide">
Email address
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === 'submitting'}
placeholder="you@example.com"
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-white/90 text-sm placeholder:text-white/20 focus:outline-none focus:border-indigo-400/50 focus:bg-white/[0.06] transition-colors disabled:opacity-50"
/>
{status === 'error' && message && (
<p className="mt-2 text-rose-300/80 text-xs">{message}</p>
)}
<button
type="submit"
disabled={status === 'submitting' || !email.trim()}
className="w-full mt-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg px-4 py-3 transition-all"
>
{status === 'submitting' ? 'Sending…' : 'Send access link'}
</button>
</form>
)}
<div className="border-t border-white/[0.06] pt-5"> <div className="border-t border-white/[0.06] pt-5">
<p className="text-white/30 text-xs"> <p className="text-white/30 text-xs">
Questions? Contact us at{' '} Questions? Contact us at{' '}