Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ GuardScope/
- **Database/Auth:** Supabase PostgreSQL, Supabase Auth, RLS
- **Threat intel:** VirusTotal, Google Safe Browsing, PhishTank, URLhaus, Spamhaus
- **Rate limiting:** Upstash Redis
- **Payments:** Stripe and Paystack, currently suspended during the launch-code promo period
- **Payments:** Paystack recurring subscriptions (credential-gated until activation)
- **Email:** Resend
- **Monitoring:** Sentry

## Quotas And Beta Access
## Access Model

- Anonymous users: 5 analyses per day per IP.
- Signed-in free users: 5 analyses per month per account.
- Pro and Team users: unlimited analyses, subject to abuse rate limits.
- Migration-safe `legacy` mode preserves the currently published extension.
- Trial mode requires an account and provides five lifetime trial scans.
- Pro and Team access is subscription-authoritative, subject to abuse rate limits.
- Launch promo codes: 100 seeded `GS-...` codes, each granting 30 days of Pro access after redemption.

## Privacy Model
Expand Down
12 changes: 5 additions & 7 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ NEXT_PUBLIC_APP_URL=https://guardscope.app
NEXT_PUBLIC_SITE_URL=https://guardscope.app
NEXT_PUBLIC_EXTENSION_STATUS=early_access
NEXT_PUBLIC_CHROME_WEB_STORE_URL=
GUARDSCOPE_ACCESS_MODE=legacy

# InceptionLabs Mercury-2 (required)
INCEPTION_API_KEY=
Expand All @@ -28,11 +29,8 @@ RESEND_API_KEY=
# Sentry
SENTRY_DSN=

# Stripe (payments currently suspended during early access)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PRICE_ID=

# Paystack (payments currently suspended during early access)
# Paystack (keep PAYMENTS_ENABLED=false until every live value is configured)
PAYMENTS_ENABLED=false
PAYSTACK_SECRET_KEY=
PAYSTACK_PRO_PLAN_CODE=
PAYSTACK_PRO_MONTHLY_PLAN_CODE=
PAYSTACK_PRO_ANNUAL_PLAN_CODE=
23 changes: 23 additions & 0 deletions backend/PAYSTACK_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# GuardScope Paystack activation

The subscription code is deploy-safe while billing is disabled. Keep these values in place until the Paystack plans and webhook are ready:

```env
GUARDSCOPE_ACCESS_MODE=legacy
PAYMENTS_ENABLED=false
PAYSTACK_SECRET_KEY=
PAYSTACK_PRO_MONTHLY_PLAN_CODE=
PAYSTACK_PRO_ANNUAL_PLAN_CODE=
```

## Activation order

1. Apply `supabase/migrations/20260811160000_subscription_foundation.sql`.
2. Create monthly and annual recurring plans in Paystack, both in NGN.
3. Configure the Paystack webhook URL as `https://guardscope.app/api/paystack/webhook`.
4. Add the live secret key and both plan codes to the production environment.
5. Set `PAYMENTS_ENABLED=true` and verify both checkout intervals with a real low-value test plan before launch pricing is finalized.
6. Set `GUARDSCOPE_ACCESS_MODE=trial` to require accounts and enforce the five lifetime trial scans.
7. After checkout, renewal, failed-payment, cancellation, and management-link checks pass, set `GUARDSCOPE_ACCESS_MODE=paid`.

`legacy` preserves the currently published extension behavior. `trial` and `paid` use the same server-authoritative entitlement record; neither mode trusts a counter stored in the browser extension.
126 changes: 126 additions & 0 deletions backend/app/account/AccountDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'use client'

import { useEffect, useState } from 'react'

interface AccountStatus {
email: string | null
accessPlan: 'trial' | 'pro' | 'team'
subscriptionStatus: string
trialScansUsed: number
trialScanLimit: number
trialScansRemaining: number
paymentReady: boolean
currentPeriodEnd: string | null
nextPaymentAt: string | null
cancelAtPeriodEnd: boolean
}

export function AccountDashboard() {
const [status, setStatus] = useState<AccountStatus | null>(null)
const [error, setError] = useState('')
const [billingLoading, setBillingLoading] = useState(false)

useEffect(() => {
fetch('/api/auth/session', { method: 'POST', cache: 'no-store' })
.then(() => fetch('/api/account/status', { cache: 'no-store' }))
.then(async (res) => {
if (res.status === 401) {
window.location.href = '/signup'
return null
}
if (!res.ok) throw new Error('Account status is temporarily unavailable.')
return res.json() as Promise<AccountStatus>
})
.then((data) => data && setStatus(data))
.catch((reason: Error) => setError(reason.message))
}, [])

async function startCheckout(interval: 'monthly' | 'annual') {
setBillingLoading(true)
setError('')
try {
const res = await fetch('/api/paystack/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval }),
})
const data = await res.json() as { url?: string; message?: string; error?: string }
if (!res.ok || !data.url) throw new Error(data.message ?? data.error ?? 'Billing is not available yet.')
window.location.href = data.url
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Billing is not available yet.')
setBillingLoading(false)
}
}

async function manageBilling() {
setBillingLoading(true)
setError('')
try {
const res = await fetch('/api/paystack/manage', { method: 'POST' })
const data = await res.json() as { url?: string; message?: string; error?: string }
if (!res.ok || !data.url) throw new Error(data.message ?? data.error ?? 'Unable to open billing management.')
window.location.href = data.url
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to open billing management.')
setBillingLoading(false)
}
}

async function signOut() {
await fetch('/api/auth/signout', { method: 'POST' })
window.location.href = '/'
}

if (!status && !error) return <div className="account-loading">Loading your protection status…</div>

return (
<div className="account-dashboard">
{error && <div className="account-alert">{error}</div>}
{status && (
<>
<section className="account-hero-card">
<div>
<span className="section-kicker">Protection account</span>
<h1>{status.accessPlan === 'trial' ? 'Your GuardScope trial' : 'GuardScope Pro is active'}</h1>
<p>{status.email}</p>
</div>
<span className={`status-pill status-${status.subscriptionStatus}`}>{status.subscriptionStatus.replace('_', ' ')}</span>
</section>

<section className="account-metric-grid">
<article className="account-metric">
<span>Plan</span><strong>{status.accessPlan === 'trial' ? '5-scan trial' : status.accessPlan.toUpperCase()}</strong>
</article>
<article className="account-metric">
<span>Trial scans remaining</span><strong>{status.trialScansRemaining}</strong>
<small>{status.trialScansUsed} of {status.trialScanLimit} used</small>
</article>
<article className="account-metric">
<span>Next billing date</span><strong>{status.nextPaymentAt ? new Date(status.nextPaymentAt).toLocaleDateString() : '—'}</strong>
</article>
</section>

<section className="account-billing-card">
<div>
<span className="section-kicker">Billing</span>
<h2>{status.paymentReady ? 'Choose your protection plan' : 'Paystack connection pending'}</h2>
<p>{status.paymentReady ? 'Secure recurring billing managed by Paystack.' : 'Your account and trial are ready. Subscription checkout will unlock automatically when the live Paystack details are connected.'}</p>
</div>
<div className="account-actions">
{status.accessPlan === 'trial' ? (
<>
<button disabled={!status.paymentReady || billingLoading} onClick={() => startCheckout('monthly')}>Subscribe monthly</button>
<button className="secondary" disabled={!status.paymentReady || billingLoading} onClick={() => startCheckout('annual')}>Subscribe annually</button>
</>
) : (
<button disabled={!status.paymentReady || billingLoading} onClick={manageBilling}>Manage subscription</button>
)}
<button className="text-button" onClick={signOut}>Sign out</button>
</div>
</section>
</>
)}
</div>
)
}
22 changes: 22 additions & 0 deletions backend/app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Navbar } from '../components/Navbar'
import { Footer } from '../components/Footer'
import { AccountDashboard } from './AccountDashboard'

export const metadata: Metadata = {
title: 'Account',
description: 'Manage your GuardScope trial, protection plan, and billing status.',
robots: { index: false, follow: false },
}

export default function AccountPage() {
return (
<>
<Navbar activePage="/account" />
<main className="account-page-shell">
<AccountDashboard />
</main>
<Footer />
</>
)
}
21 changes: 21 additions & 0 deletions backend/app/api/account/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { authenticateRequest } from '../../../../lib/requestAuth'
import { getAccountStatus } from '../../../../lib/access'
import { buildCorsHeaders } from '../../../../lib/cors'

export async function OPTIONS(req: NextRequest) {
return new NextResponse(null, { status: 204, headers: buildCorsHeaders(req, 'GET, OPTIONS') })
}

export async function GET(req: NextRequest) {
const headers = buildCorsHeaders(req, 'GET, OPTIONS')
const auth = await authenticateRequest(req)
if (!auth) return NextResponse.json({ error: 'Authentication required' }, { status: 401, headers })

const status = await getAccountStatus(auth.userId)
if (!status) return NextResponse.json({ error: 'Account status unavailable' }, { status: 503, headers })

return NextResponse.json(status, {
headers: { ...headers, 'Cache-Control': 'no-store, max-age=0' },
})
}
53 changes: 40 additions & 13 deletions backend/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getCachedResult, setCachedResult } from '../../../lib/cache'
import { createHash } from 'crypto'
import { buildCorsHeaders } from '../../../lib/cors'
import { logOperationalEvent } from '../../../lib/operationalEvents'
import { consumeTrialScan, getAccessMode, getAccountStatus } from '../../../lib/access'

const MAX_BODY_BYTES = 500_000

Expand Down Expand Up @@ -247,25 +248,51 @@ export async function POST(req: NextRequest) {
)
}

if (userId) {
const tier = await getUserTier(userId)
const quota = await checkAndIncrementQuota(userId, tier)
if (!quota.allowed) {
if (getAccessMode() === 'legacy') {
if (userId) {
const tier = await getUserTier(userId)
const quota = await checkAndIncrementQuota(userId, tier)
if (!quota.allowed) {
return NextResponse.json(
{ error: 'limit_reached', count: quota.count, limit: quota.limit, message: 'Monthly analysis limit reached. Upgrade to Pro for unlimited analyses.' },
{ status: 429, headers: SECURITY_HEADERS }
)
}
} else {
const quota = await checkAnonFreeQuota(ip)
if (!quota.allowed) {
return NextResponse.json(
{ error: 'limit_reached', count: quota.count, limit: quota.limit, message: 'Daily analysis limit reached. Sign in to continue.' },
{ status: 429, headers: SECURITY_HEADERS }
)
}
}
} else {
if (!userId) {
return NextResponse.json(
{ error: 'limit_reached', count: quota.count, limit: quota.limit, message: 'Monthly analysis limit reached. Upgrade to Pro for unlimited analyses.' },
{ status: 429, headers: SECURITY_HEADERS }
{ error: 'account_required', message: 'Sign in to use your GuardScope trial or subscription.' },
{ status: 401, headers: SECURITY_HEADERS }
)
}
} else {
// Anonymous users: 5 analyses per day per IP. Unknown IPs are bucketed
// together instead of skipping quota enforcement.
const quota = await checkAnonFreeQuota(ip)
if (!quota.allowed) {
const account = await getAccountStatus(userId)
if (!account) {
return NextResponse.json({ error: 'account_status_unavailable' }, { status: 503, headers: SECURITY_HEADERS })
}
if (!account.entitled) {
return NextResponse.json(
{ error: 'limit_reached', count: quota.count, limit: quota.limit, message: 'Daily analysis limit reached. Sign in to continue.' },
{ status: 429, headers: SECURITY_HEADERS }
{ error: 'subscription_required', count: account.trialScansUsed, limit: account.trialScanLimit, message: 'Your trial is complete. Subscribe to continue scanning.' },
{ status: 402, headers: SECURITY_HEADERS }
)
}
if (account.accessPlan === 'trial') {
const trial = await consumeTrialScan(userId)
if (!trial.allowed) {
return NextResponse.json(
{ error: 'subscription_required', count: trial.scansUsed, limit: trial.scanLimit, message: 'Your trial is complete. Subscribe to continue scanning.' },
{ status: 402, headers: SECURITY_HEADERS }
)
}
}
}

// ── Result cache — same email always returns same score within 24h ─────────
Expand Down
3 changes: 3 additions & 0 deletions backend/app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { buildCorsHeaders, isAllowedExtensionRequest } from '../../../../lib/cors'
import { checkRateLimit } from '../../../../lib/ratelimit'
import { getUserTier } from '../../../../lib/quota'
import { getAccountStatus } from '../../../../lib/access'

const SUPABASE_URL = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL)!
const SUPABASE_ANON_KEY = (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_ANON_KEY)!
Expand Down Expand Up @@ -59,13 +60,15 @@ export async function POST(req: NextRequest) {
}

const tier = await getUserTier(data.user.id)
const account = await getAccountStatus(data.user.id)
return NextResponse.json({
success: true,
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
user: data.user,
tier,
account,
}, { headers: cors })
} catch {
return NextResponse.json({ error: 'Network error - please try again' }, { status: 500, headers: cors })
Expand Down
45 changes: 45 additions & 0 deletions backend/app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
import { ACCESS_COOKIE, REFRESH_COOKIE, authCookieOptions } from '../../../../lib/requestAuth'

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL ?? ''
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_ANON_KEY ?? ''

export async function POST(req: NextRequest) {
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value
if (!refreshToken || !SUPABASE_URL || !SUPABASE_ANON_KEY) {
return NextResponse.json({ error: 'Session unavailable' }, { status: 401 })
}

try {
const authRes = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: SUPABASE_ANON_KEY },
body: JSON.stringify({ refresh_token: refreshToken }),
cache: 'no-store',
})
if (!authRes.ok) {
const response = NextResponse.json({ error: 'Session expired' }, { status: 401 })
response.cookies.delete(ACCESS_COOKIE)
response.cookies.delete(REFRESH_COOKIE)
return response
}

const session = await authRes.json() as {
access_token: string
refresh_token: string
expires_in?: number
}
const response = NextResponse.json({ success: true })
response.cookies.set(ACCESS_COOKIE, session.access_token, {
...authCookieOptions,
maxAge: session.expires_in ?? 3600,
})
response.cookies.set(REFRESH_COOKIE, session.refresh_token, {
...authCookieOptions,
maxAge: 60 * 60 * 24 * 30,
})
return response
} catch {
return NextResponse.json({ error: 'Session refresh unavailable' }, { status: 503 })
}
}
Loading
Loading