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
mobileLinkAccountsmerge path is disabled (returns "temporarily unavailable" pending secondary-account ownership verification). - Soft delete:
requestAccountDeletionsetsaccountStatus: "deleted"+isActive: false. Admins cannot self-delete. - Hard delete (
convex/auth/hardDelete.ts) runs three steps in order: deleteuserProfiles(aggregate before the row), deletebeautyProfiles, 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
shopifySyncJobsrow and a durable workflow creates the Admin API customer; status mirrors onto the profile.
Gotchas
SITE_URLmust be the.convex.siteendpoint (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.