Free interactive demo
The one-shot free demo, its 8 anti-abuse gates, and how to test them.
The free interactive demo is the only free experience under the trial-strict model: a signed-in, email-verified visitor consumes one demo, once in their lifetime — the opening-hand consistency of a deck (exact, zero AI) plus one real AI analysis — then a CTA to the paid 7-day trial.
The whole limit is authoritative server-side (convex/demo/); no client-only
check is trusted. Tampering with the front-end or calling the Convex action
directly still hits the same gates.
Where it lives
| Layer | Path |
|---|---|
| Gate (pure) | convex/demo/limits.ts — assertDemoAvailable, hasForeignClaim |
| Gate context | convex/demo/queries.ts — _loadDemoGateContext, _findForeignDeviceClaim |
| One-shot + ledger | convex/demo/mutations.ts — _claimDemoTrial, _releaseDemoTrial, _recordDeviceClaim |
| Action | convex/demo/userActions.ts — runDemo |
| Consistency (pure) | convex/demo/consistency.ts |
| UI | src/routes/(logged-in)/demo/, src/features/demo/ |
The 8 anti-abuse gates
A visitor who tries to get more than their one demo hits, in order:
| # | Vector | Defense | Result |
|---|---|---|---|
| 1 | Re-run with the same account | userProfiles.demoTrialUsedAt (one-shot) | DEMO_ALREADY_USED |
| 2 | Fire N runs in parallel (race) | atomic _claimDemoTrial (transactional, reserved before the Claude call) | DEMO_ALREADY_USED |
| 3 | Skip email verification | emailVerified === true required | DEMO_EMAIL_UNVERIFIED |
| 4 | Disposable / temp email | disposable_email risk flag (set at signup) | DEMO_BLOCKED_RISK (disposable_email) |
| 5 | VPN / proxy | vpn_signup risk flag (proxycheck.io at signup) | DEMO_BLOCKED_RISK (vpn) |
| 6 | Many accounts from one IP | signup_rate_limit risk flag (>3 signups/IP/24h at signup) | DEMO_BLOCKED_RISK (rate_limit) |
| 7 | 2nd account, same device/browser | demoTrialClaims ledger by server-issued device id (httpOnly demo_device cookie, HMAC-signed by /api/demo/run) | DEMO_BLOCKED_RISK (device) |
| 8 | 2nd account, same IP/network | IP arm of the ledger — audit-only | allowed + console.warn audit log |
Gate order in runDemo: email verified → VPN/disposable/rate-limit flags →
already used → foreign device id → atomic one-shot claim → analysis → record the
claim (only on success, so a failed/released demo leaves no trace).
Why the device id is server-issued (not a client fingerprint)
runDemo is a public Convex action: anyone could call it directly and pass
any device value, so a browser-computed fingerprint is not a trust boundary. The
device id is instead minted by the /api/demo/run server route, stored in an
httpOnly cookie the page JS can't read or forge, and signed into a
short-lived HMAC token (convex/demo/deviceToken.ts, secret DEMO_DEVICE_SECRET).
The action only trusts a device id whose token verifies — a direct caller that
bypasses the route can't produce one, so its device id is ignored.
Why IP is audit-only
Blocking on IP punishes legitimate visitors who share an address (corporate NAT,
public Wi-Fi, mobile CGNAT, same household). So the device id blocks but the
IP only logs a foreign claim (the per-IP signup velocity gate #6 is the
network-level lever instead). The IP is still recorded in demoTrialClaims, so
the block can be re-tightened later without a migration.
Known limits (honest)
- Clearing the
demo_devicecookie / incognito + a new verified non-disposable email + a fresh IP still gets a demo. The goal is to make farming expensive (now: a new cookie jar AND a new mailbox AND a new IP — the last tripping the signup rate-limit gate), not impossible (a demo costs ~$0.01). - A missing
DEMO_DEVICE_SECRET(either runtime) or an absent cookie skips the device arm; email + flags + the one-shot still apply.
Testing the demo (dev / preview)
A dev-only seam, convex/dev/seedDemoTest.ts, makes the demo testable without
manual DB edits. It self-gates on isDevBypassAllowed() (DEV_BYPASS_ENABLED=1
- non-prod
SITE_URL) — a hard no-op on prod.
# enable the seam + start the app (local)
pnpm exec convex env set DEV_BYPASS_ENABLED 1
pnpm start-allseedDemoTest:run ensures the dev-bypass user, forces emailVerified=true,
resets the one-shot + the device ledger + any injected flags/claims, and
returns a paste-ready valid 50-card decklist. A no-arg run = clean, replayable
state.
# happy path — verified user + valid deck, ready to run
pnpm exec convex run dev/seedDemoTest:run
# → sign in via "Sign in as test user", open /demo, pick the seeded
# "Test Luffy Aggro" deck (or paste the returned decklist), Run.One command per gate
# 4 — disposable-email block
pnpm exec convex run dev/seedDemoTest:run '{"injectRiskFlag":"disposable_email"}'
# 5 — VPN block
pnpm exec convex run dev/seedDemoTest:run '{"injectRiskFlag":"vpn_signup"}'
# 6 — signup rate-limit block (too many accounts from one IP)
pnpm exec convex run dev/seedDemoTest:run '{"injectRiskFlag":"signup_rate_limit"}'
# 8 — IP (audit-only): injects a foreign claim on YOUR session IP; the demo
# still passes and a "[demo] foreign demo claim on same IP — audit" line
# appears in the Convex logs.
pnpm exec convex run dev/seedDemoTest:run '{"injectForeignClaim":{"ip":true}}'
# 7 — device block: the device id now lives in the httpOnly `demo_device` cookie
# (not the page JS). Read it in DevTools → Application → Cookies → demo_device,
# then inject a foreign claim on it (the ledger field is still `fingerprintHash`):
pnpm exec convex run dev/seedDemoTest:run '{"injectForeignClaim":{"fingerprintHash":"<demo_device-cookie-value>"}}'
# reset to a clean state (clears flags, one-shot, injected + real claims)
pnpm exec convex run dev/seedDemoTest:run- 1 — already used: run the happy path, complete the demo, reload
/demo→ the "essai consommé" state with the trial CTA. - 3 — email unverified: use a brand-new real signup (don't click the verification link). The dev-bypass user is force-verified by the seam, so it won't show this state.
- 2 — race: guaranteed by the transactional
_claimDemoTrial; not a reliable click-test. Covered by design. - 7 — device: easiest reproduced the genuine way — consume the demo on account
A, then sign up account B in the same browser (verified) → the shared
demo_devicecookie makes B a foreign claim → blocked.
On Vercel previews, the build pipeline (scripts/convex-preview-deploy.sh)
runs dev/seedDemoTest:run automatically, so the dev-bypass user is already
verified with a valid deck — just sign in and open /demo. For the injection
scenarios on a preview, run the same command with --deployment preview/<branch>
(needs the preview deploy key).
Cost
Each demo bills one real analysis to the existing AI budget (endpoint demo,
~$0.01 with prompt caching). For cost-free local/preview testing, set
AI_ANALYSIS_MOCK=1 on the deployment to return a canned analysis with zero
tokens.