Free interactive demo

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

LayerPath
Gate (pure)convex/demo/limits.tsassertDemoAvailable, hasForeignClaim
Gate contextconvex/demo/queries.ts_loadDemoGateContext, _findForeignDeviceClaim
One-shot + ledgerconvex/demo/mutations.ts_claimDemoTrial, _releaseDemoTrial, _recordDeviceClaim
Actionconvex/demo/userActions.tsrunDemo
Consistency (pure)convex/demo/consistency.ts
UIsrc/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:

#VectorDefenseResult
1Re-run with the same accountuserProfiles.demoTrialUsedAt (one-shot)DEMO_ALREADY_USED
2Fire N runs in parallel (race)atomic _claimDemoTrial (transactional, reserved before the Claude call)DEMO_ALREADY_USED
3Skip email verificationemailVerified === true requiredDEMO_EMAIL_UNVERIFIED
4Disposable / temp emaildisposable_email risk flag (set at signup)DEMO_BLOCKED_RISK (disposable_email)
5VPN / proxyvpn_signup risk flag (proxycheck.io at signup)DEMO_BLOCKED_RISK (vpn)
6Many accounts from one IPsignup_rate_limit risk flag (>3 signups/IP/24h at signup)DEMO_BLOCKED_RISK (rate_limit)
72nd account, same device/browserdemoTrialClaims ledger by server-issued device id (httpOnly demo_device cookie, HMAC-signed by /api/demo/run)DEMO_BLOCKED_RISK (device)
82nd account, same IP/networkIP arm of the ledger — audit-onlyallowed + 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_device cookie / 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-all

seedDemoTest: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_device cookie 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.