Feature: Free Credit Abuse Prevention
Metadata
- Issue ID: FEAT-119
- Status: Done
- Owner: danrave1234
- Related PRs:
feature/119-free-credit-abuse-prevention->dev(StudyBoostIO/StudyboostV2#121, closes #119)
Overview
FEAT-84's follow-up made AI Courses, AI Tools, and document uploads free-with-credits by auto-provisioning a user_subscriptions row for every user. That left an obvious abuse vector: sign up with N email aliases and stack free credits indefinitely. FEAT-119 closes the gap with four layered, low-cost defenses on top of the existing auth + subscription flow — no new infrastructure.
- Email-verification gate — spending AI credits or upload slots requires
users.email_verified = true; browsing/reading remains open. - Disposable email blocklist — signups from temp-mail providers are rejected with
400 BAD_REQUESTbefore any DB write. - Gmail dot +
+aliascanonicalization —user.name+free@gmail.com,u.s.e.r@gmail.com, anduser@gmail.comall collapse to onecanonical_email; a unique index blocks duplicate signups. - Per-IP signup rate limit — application-level throttle (default 3 signups per IP per 24h), layered on top of the existing
AuthThrottlerGuard.
The PR also ships the missing /verify-email page so the verification flow (whose backend shipped in FEAT-31 / FEAT-105) is end-to-end clickable.
Canonical feature doc: docs/features/FEAT-119-free-credit-abuse-prevention.md.
Frontend Behavior
/verify-email?token=<opaque> (new page)
- Auto-POSTs the token to
/auth/verify-emailon mount. - Renders one of:
verifying,success(with link to/ai-courses),used,expired,invalid,error. - A React 18 StrictMode guard prevents the dev double-mount from burning the one-time token.
- Layout sets
referrer: no-referrer+robots: noindex(mirrors/reset-password) so the token never leaks via Referer headers or search engines.
Email-verification banner
frontend/components/auth/email-verification-banner.tsx — a yellow banner mounted on:
/ai-courses(surface="AI Courses")/ai/*AI Lab (surface="the AI Lab")- the upload modal inside
DocumentPicker(surface="document uploads")
Renders only when useAuthUser() returns email_verified !== true. Includes a "Resend verification email" button that POSTs to /auth/resend-verification, with inline loading / success / error states.
Error codes the frontend branches on
| Code | When | UX |
|---|---|---|
EMAIL_NOT_VERIFIED | 403 from any @RequireFeature('AI_CREDIT' | 'UPLOAD') route | Render EmailVerificationBanner; no generic toast |
BAD_REQUEST (disposable copy) | 400 from /auth/signup | Inline form error: "This email provider is not supported." |
TOO_MANY_REQUESTS | 429 from /auth/signup | Generic "try again later" — must not leak whether the email exists |
CONFLICT ("already in use") | 409 from /auth/signup (canonical collision) | Identical copy to a literal-email duplicate so attackers can't differentiate |
Bespoke inline copy for EMAIL_DISPOSABLE / 429 is deferred — the existing generic toast renders the server's user-readable message.
Backend Behavior
PlanAccessGuard — email_verified gate
backend/src/common/guards/plan-access.guard.ts. When a route's @RequireFeature is AI_CREDIT or UPLOAD, the guard fetches users.email_verified after the active-subscription check. Unverified users get:
HTTP/1.1 403 Forbidden
{ "code": "EMAIL_NOT_VERIFIED", "message": "Please verify your email to use AI Courses, AI Tools, and document uploads.", "status": 403 }
The code is propagated via ForbiddenException({ error: EMAIL_NOT_VERIFIED, message }), which AppExceptionFilter maps to body.code. Read endpoints (list courses, view course, list documents, fetch tool catalog) have no @RequireFeature decorator and pass unchanged.
Disposable email blocklist
Static snapshot at backend/src/auth/data/disposable-domains.json — 5,467 domains sourced from the upstream disposable-email-domains project (refresh quarterly). Util in backend/src/auth/utils/disposable-email.util.ts. AuthService.signup calls assertNotDisposable(email) before any DB write. Admin override via AUTH_DISPOSABLE_ALLOWLIST=foo.com,bar.com. Rejection: 400 BAD_REQUEST with "This email provider is not supported."
Canonical email — Gmail dot/+alias
Migration backend/prisma/migrations/202605200257_add_users_canonical_email/migration.sql adds users.canonical_email VARCHAR(255) (nullable) plus a partial unique index idx_users_canonical_email (excludes NULLs). Data risk: none. Util in backend/src/auth/utils/canonical-email.util.ts — for Gmail domains (gmail.com, googlemail.com) it strips +alias and removes all dots; non-Gmail emails are lowercased only. AuthService.signup and handleGoogleCallback both compute canonical_email, look up with OR: [{ email }, { canonical_email }, { username }], throw 409 "Email is already in use" on a match, and persist both fields on create. Legacy-row backfill runs on-demand via the login flow's ensureFreeSubscription companion.
Per-IP signup rate limit
AuthService.enforceSignupRateLimit (called at the top of signup()) counts security_events of type SIGNUP_ATTEMPT with ip_address = req.ip in the last 24h. Threshold from AUTH_SIGNUP_RATE_LIMIT_PER_DAY (default 3). On hit: 429 TOO_MANY_REQUESTS with generic copy. This is in addition to the existing AuthThrottlerGuard (100 req/min/IP across /auth/*).
Interaction with FEAT-105 Google OAuth
- Google delivers
email_verified=truefor verified accounts -> these users immediately pass the gate; no banner. - The disposable check also runs on the Google email (defense in depth).
canonical_emailis computed and persisted for OAuth signups so Gmail+aliastricks can't bypass uniqueness.
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-119-01 | Happy path: unverified user opens /ai-courses | Sign up -> log in -> navigate to /ai-courses | New unverified account | Page renders, list shows, banner with surface AI Courses visible; no 403 |
| FEAT-119-02 | Unauthorized: unverified user POSTs /generated-courses | Sign up -> log in -> POST with any UUID | Unverified account | 403 EMAIL_NOT_VERIFIED; no credit consumed; no course row created |
| FEAT-119-03 | Happy path: after verification the banner disappears | FEAT-119-02 user -> POST /auth/verify-email with fresh token -> reload /ai-courses | Valid verification token | Banner not in DOM; POST /generated-courses proceeds past the gate |
| FEAT-119-04 | Validation: signup with disposable email | POST /auth/signup with *@mailinator.com | Disposable domain email | 400 BAD_REQUEST with disposable message; no users row |
| FEAT-119-05 | Validation: allowlist override | Set AUTH_DISPOSABLE_ALLOWLIST=mailinator.com, repeat FEAT-119-04 | Allowlisted disposable domain | Signup succeeds (201) |
| FEAT-119-06 | Validation: Gmail dot/+alias dedup | Sign up user.canon@gmail.com, then usercanon+free@gmail.com | Two Gmail aliases of one canonical | First 201, second 409 "Email is already in use" |
| FEAT-119-07 | Rate limit / abuse: per-IP signup throttle | Attempt a 4th signup from the same IP within 24h | 4th signup, same IP | 429 TOO_MANY_REQUESTS; generic copy — no leak about email existence |
| FEAT-119-08 | Backend failure: invalid verification token | Visit /verify-email?token=garbage | Malformed/unknown token | API returns error state; error copy rendered with a link back to /login |
| FEAT-119-09 | Backend failure: missing verification token | Visit /verify-email with no query string | No token param | Error copy rendered immediately; no API call burned |
| FEAT-119-10 | Expiry/refresh: expired verification token | Click a verification link whose token has expired | Expired token | expired state rendered with a resend affordance |
| FEAT-119-11 | Logout/revocation: pre-119 verified user retains access | Existing verified user signs in, POSTs /generated-courses | Verified legacy account | Passes the verification gate (email_verified=true) and consumes a credit normally |
| FEAT-119-12 | OAuth: Google signup is verified on first try | "Continue with Google" with a Google-verified email | Google-verified email | New users row has email_verified=true + canonical_email; no banner; POST /generated-courses works first try |
Automated coverage: frontend/e2e/free-credit-abuse-prevention.spec.ts exercises FEAT-119-01..06 + FEAT-119-08..09 against the live backend. The verify-flow test uses backend/scripts/peek-verification-token.ts to issue a fresh token without the real mail sender. The spec requires DEV_BYPASS_SUBSCRIPTION=false in backend/.env — the dev bypass short-circuits PlanAccessGuard and is enabled by default in local dev.
Edge Cases
- OAuth sign-in delivers a verified email —
email_verified=trueat row creation; the user never sees the banner. - Email-change flow — changing email resets
email_verifiedto false (existing behavior); the credit guard blocks until the new address is verified. - Stripe webhook upgrade race — a free user who upgrades to Pro before verifying still hits the
email_verifiedcheck (by design — verifying email is plan-agnostic and prevents stolen-card abuse). - Disposable list freshness — the vendored snapshot accepts staleness; it is never fetched from upstream at runtime to avoid inheriting upstream availability risk.
- Canonical backfill collisions — when two legacy rows would collapse to the same canonical, the partial unique index (
WHERE canonical_email IS NOT NULL) lets them coexist as NULL until an admin resolves; a follow-up script keeps the oldest row. - Per-IP throttle test isolation — Playwright runs hit
127.0.0.1and accumulate; the spec uses--workers=1and a high default, and CI may bumpAUTH_SIGNUP_RATE_LIMIT_PER_DAYor reset state between runs. - Network failure on resend — the banner's "Resend verification email" button surfaces an inline error state without losing the page.
Notes
- Feature flags / env:
AUTH_DISPOSABLE_ALLOWLIST,AUTH_SIGNUP_RATE_LIMIT_PER_DAY(default 3),DEV_BYPASS_SUBSCRIPTION(short-circuitsPlanAccessGuardin local dev). - Dependencies: FEAT-84 (free-tier
user_subscriptionsauto-provisioning), FEAT-105 (Google OAuth,/auth/verify-email+/auth/resend-verificationendpoints), FEAT-31 (cookie auth,AuthThrottlerGuard). - Known limitations: the migration does not backfill legacy
canonical_emailvalues — backfill runs on-demand via the login flow. - Out of scope (deferred): phone/SMS verification, captcha, hardware fingerprinting, 2FA enrolment.
EMAIL_NOT_VERIFIEDis exported fromplan-access.guard.tsas a constant so the frontend banner predicate and the spec assertions import the same source-of-truth string.
Related Pages
/docs/features/84-ai-course-generation— the follow-up that made AI features free-with-credits/docs/features/105-google-oauth-and-password-reset— OAuth + verification-email backend/docs/features/qa-requirements— QA scenario coverage rules