StudyBoost Docs

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.

  1. Email-verification gate — spending AI credits or upload slots requires users.email_verified = true; browsing/reading remains open.
  2. Disposable email blocklist — signups from temp-mail providers are rejected with 400 BAD_REQUEST before any DB write.
  3. Gmail dot + +alias canonicalizationuser.name+free@gmail.com, u.s.e.r@gmail.com, and user@gmail.com all collapse to one canonical_email; a unique index blocks duplicate signups.
  4. 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-email on 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

CodeWhenUX
EMAIL_NOT_VERIFIED403 from any @RequireFeature('AI_CREDIT' | 'UPLOAD') routeRender EmailVerificationBanner; no generic toast
BAD_REQUEST (disposable copy)400 from /auth/signupInline form error: "This email provider is not supported."
TOO_MANY_REQUESTS429 from /auth/signupGeneric "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

PlanAccessGuardemail_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=true for verified accounts -> these users immediately pass the gate; no banner.
  • The disposable check also runs on the Google email (defense in depth).
  • canonical_email is computed and persisted for OAuth signups so Gmail +alias tricks can't bypass uniqueness.

QA Test Scenarios

Scenario IDDescriptionStepsInputExpected Result
FEAT-119-01Happy path: unverified user opens /ai-coursesSign up -> log in -> navigate to /ai-coursesNew unverified accountPage renders, list shows, banner with surface AI Courses visible; no 403
FEAT-119-02Unauthorized: unverified user POSTs /generated-coursesSign up -> log in -> POST with any UUIDUnverified account403 EMAIL_NOT_VERIFIED; no credit consumed; no course row created
FEAT-119-03Happy path: after verification the banner disappearsFEAT-119-02 user -> POST /auth/verify-email with fresh token -> reload /ai-coursesValid verification tokenBanner not in DOM; POST /generated-courses proceeds past the gate
FEAT-119-04Validation: signup with disposable emailPOST /auth/signup with *@mailinator.comDisposable domain email400 BAD_REQUEST with disposable message; no users row
FEAT-119-05Validation: allowlist overrideSet AUTH_DISPOSABLE_ALLOWLIST=mailinator.com, repeat FEAT-119-04Allowlisted disposable domainSignup succeeds (201)
FEAT-119-06Validation: Gmail dot/+alias dedupSign up user.canon@gmail.com, then usercanon+free@gmail.comTwo Gmail aliases of one canonicalFirst 201, second 409 "Email is already in use"
FEAT-119-07Rate limit / abuse: per-IP signup throttleAttempt a 4th signup from the same IP within 24h4th signup, same IP429 TOO_MANY_REQUESTS; generic copy — no leak about email existence
FEAT-119-08Backend failure: invalid verification tokenVisit /verify-email?token=garbageMalformed/unknown tokenAPI returns error state; error copy rendered with a link back to /login
FEAT-119-09Backend failure: missing verification tokenVisit /verify-email with no query stringNo token paramError copy rendered immediately; no API call burned
FEAT-119-10Expiry/refresh: expired verification tokenClick a verification link whose token has expiredExpired tokenexpired state rendered with a resend affordance
FEAT-119-11Logout/revocation: pre-119 verified user retains accessExisting verified user signs in, POSTs /generated-coursesVerified legacy accountPasses the verification gate (email_verified=true) and consumes a credit normally
FEAT-119-12OAuth: Google signup is verified on first try"Continue with Google" with a Google-verified emailGoogle-verified emailNew 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 emailemail_verified=true at row creation; the user never sees the banner.
  • Email-change flow — changing email resets email_verified to 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_verified check (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.1 and accumulate; the spec uses --workers=1 and a high default, and CI may bump AUTH_SIGNUP_RATE_LIMIT_PER_DAY or 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-circuits PlanAccessGuard in local dev).
  • Dependencies: FEAT-84 (free-tier user_subscriptions auto-provisioning), FEAT-105 (Google OAuth, /auth/verify-email + /auth/resend-verification endpoints), FEAT-31 (cookie auth, AuthThrottlerGuard).
  • Known limitations: the migration does not backfill legacy canonical_email values — backfill runs on-demand via the login flow.
  • Out of scope (deferred): phone/SMS verification, captcha, hardware fingerprinting, 2FA enrolment.
  • EMAIL_NOT_VERIFIED is exported from plan-access.guard.ts as a constant so the frontend banner predicate and the spec assertions import the same source-of-truth string.

Related Pages