ProjectR Docs
Backend (Convex)

Auth & Roles

Better Auth, phone OTP, account lifecycle, and the role hierarchy.

Stack

Better Auth 1.6.33 (exact) + @convex-dev/better-auth 0.12.6 — moved as one atomic batch (1.7 is blocked until 0.13 ships). Plugins: expo(), crossDomain({ siteUrl }), convex() with a static JWKS env var (skips OIDC discovery per request), phoneNumber.

convex/auth.ts triggers keep userProfiles in sync with Better Auth. onCreate provisions a profile (role apps, referral code) and schedules the 30-day order claim. onUpdate mirrors email, phone and name, and reruns the claim when the email or phone changes. onDelete removes the profile and its usersByRole aggregate entry.

Phone OTP (iSMS)

6 digits, 15-minute expiry, OTP_ALLOWED_ATTEMPTS = 3. sendOTP delivers via iSMS with a mirror-endpoint fallback. Rate limits: sendOtp 3/hour, verifyOtp 10/hour.

The most security-sensitive line in the file: supplying a verifyOTP override makes Better Auth skip its own attempt tracking, so the override must re-implement it (stored "code:attempts" format, invalidation at the cap). Without it, a 6-digit code would be brute-forceable for the whole 15-minute window.

Test OTP (ALLOW_TEST_PHONE_OTP): a fixed set of E.164 numbers map to fixed codes; the flag fails closed — it requires both the flag and a SITE_URL that doesn't name the production host. Never set on production.

Roles

userProfiles.role: apps < dashboard < admin < super_admin (lowercase). Backend helpers: requireDashboardAccess, requireAdminAccess, requireSuperAdminAccess (convex/helpers/auth.ts). Admin self-signup flows through adminRequests (pending → approved/rejected).

Account lifecycle

  • Linking: Better Auth account linking is enabled; the app-level mobileLinkAccounts merge path is disabled (returns "temporarily unavailable" pending secondary-account ownership verification).
  • Soft delete: requestAccountDeletion sets accountStatus: "deleted" + isActive: false. Admins cannot self-delete.
  • Hard delete (convex/auth/hardDelete.ts) runs three steps in order: delete userProfiles (aggregate before the row), delete beautyProfiles, then delete Better Auth records session → account → user. Deleting the user row is what frees the phone/email for re-registration.
  • Shopify customer sync: signup enqueues a shopifySyncJobs row and a durable workflow creates the Admin API customer; status mirrors onto the profile.

Gotchas

  • SITE_URL must be the .convex.site endpoint (it drives OAuth redirect URIs) — not the marketing domain.
  • The Apple client secret is a JWT with ~6-month expiry — rotate on schedule.
  • Phone numbers are not unique in userProfiles; debug helpers refuse ambiguous matches.
  • Sign-in-on-verification temp emails use @phone.projectr.app; checkout and profile logic special-case them.

On this page