* Migrate authentication from Clerk to BetterAuth (#322) Replaces @clerk/react-router with self-hosted better-auth to eliminate the external Clerk dependency and keep all user/session data in our own PostgreSQL database. **What changed** - New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler - New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param - New: UserMenu component replacing Clerk's UserButton - Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable - Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9) - All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin - root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query) - useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check - models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern) - Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix - scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts) - scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export - BETTERAUTH_MIGRATION.md: dev and production runbooks - All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server - Test fixtures: added emailVerified field **Follow-up (post-stable)** - Rename actor_clerk_id column → actor_user_id in commissioner_audit_log - Drop clerk_id column from users once migration confirmed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1. The adapter works correctly at runtime with our versions — the peer dep is only for stricter type checking. This unblocks npm ci in CI without a risky drizzle major-version upgrade. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
7 KiB
BetterAuth Migration Checklist
This document covers the manual steps required to complete the Clerk → BetterAuth migration in both development and production.
What's automated (runs via scripts/migrate.mjs on every deploy, idempotent):
- Schema migrations (sessions, accounts, verifications tables; nullable clerk_id; email_verified column)
- Convert
teams.owner_idandcommissioners.user_idfrom Clerk IDs →users.idUUIDs - Mark all Clerk users as
email_verified = true - Create
accountsrecords for Google and Discord OAuth users (via Clerk API)
What's manual (one-time steps you do by hand):
- Export user CSV from Clerk (for password hash migration)
- Run the password migration script
- Update OAuth redirect URIs in Google/Discord consoles
- Set env vars in production
Development
Step 1 — Add environment variables
Add these to your .env file:
BETTER_AUTH_SECRET="<random string — run: openssl rand -base64 32>"
BETTER_AUTH_URL="http://localhost:5173"
GOOGLE_CLIENT_ID="<from Clerk dashboard → Social Connections → Google>"
GOOGLE_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Google>"
DISCORD_CLIENT_ID="<from Clerk dashboard → Social Connections → Discord>"
DISCORD_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Discord>"
# Keep temporarily for the automated OAuth migration (remove after confirmed working)
CLERK_SECRET_KEY="<your existing value>"
Remove once confirmed working: VITE_CLERK_PUBLISHABLE_KEY, CLERK_WEBHOOK_SECRET, DEV_ADMIN_CLERK_ID
Step 2 — Apply the database schema migration
npm run db:migrate
Adds the sessions, accounts, and verifications tables; makes clerk_id nullable; adds email_verified.
Step 3 — Add OAuth redirect URIs for localhost
In Google Cloud Console (APIs & Services → Credentials → your OAuth client):
- Add
http://localhost:5173/api/auth/callback/googleto Authorized redirect URIs
In Discord Developer Portal (your app → OAuth2):
- Add
http://localhost:5173/api/auth/callback/discordto Redirects
Step 4 — Run the automated data migration
node scripts/migrate.mjs
This converts owner IDs, creates Google/Discord OAuth accounts, and marks users as email-verified. It reads from the Clerk API using your CLERK_SECRET_KEY.
Step 5 — Migrate email/password users
This requires exporting password hashes from Clerk (they're only in the CSV export, not the API).
- Go to Clerk dashboard → Users → Export and download the CSV
- Save it as
exported_users.csvin the project root (it's gitignored) - Run:
node scripts/migrate-clerk-passwords.mjs - Delete
exported_users.csvimmediately after — it contains bcrypt password hashes
Step 6 — Set yourself as admin
DEV_ADMIN_CLERK_ID no longer works. Set admin access directly in the database:
UPDATE users SET is_admin = true WHERE email = 'your@email.com';
Step 7 — Verify sign-in works
- Google / Discord: sign in immediately — OAuth accounts were migrated automatically
- Email/password: sign in with the same password as before — bcrypt hashes were migrated
- Check that league ownership and commissioner access still works
After verifying, remove CLERK_SECRET_KEY from .env.
Production
Do Steps 1–3 before merging to main. The data migration (Step 4) and OAuth account creation run automatically on deploy.
Step 1 — Add production environment variables
Add to your production environment (server docker-compose / secrets manager):
BETTER_AUTH_SECRET="<strong random string — openssl rand -base64 32>"
BETTER_AUTH_URL="https://brackt.com"
GOOGLE_CLIENT_ID="<same as Clerk dashboard>"
GOOGLE_CLIENT_SECRET="<same as Clerk dashboard>"
DISCORD_CLIENT_ID="<same as Clerk dashboard>"
DISCORD_CLIENT_SECRET="<same as Clerk dashboard>"
# Keep temporarily — needed for the automated OAuth migration on first deploy
CLERK_SECRET_KEY="<your existing production value>"
Step 2 — Add OAuth redirect URIs for production
In Google Cloud Console:
- Add
https://brackt.com/api/auth/callback/googleto Authorized redirect URIs
In Discord Developer Portal:
- Add
https://brackt.com/api/auth/callback/discordto Redirects
Step 3 — Migrate email/password users (before deploying)
The password migration runs against the production database directly — it does NOT go through Docker.
- Go to Clerk dashboard → Users → Export and download the CSV for production users
- Save as
exported_users.csvin the project root (gitignored) - Run against the production database:
DATABASE_DIRECT_URL="<prod connection string>" CLERK_SECRET_KEY="<prod key>" node scripts/migrate-clerk-passwords.mjs - Delete
exported_users.csvimmediately
Step 4 — Deploy
Merge to main. The GitHub Actions deploy will:
- Build and push the Docker image
- SSH to the server and run
docker compose up --no-deps migrate scripts/migrate.mjsruns — applies schema migrations, then (becauseCLERK_SECRET_KEYis set):- Converts
teams.owner_idandcommissioners.user_idtousers.idUUIDs - Marks users as
email_verified - Creates
accountsrecords for Google and Discord OAuth users
- Converts
- The app container starts with the new BetterAuth code
Step 5 — Verify production sign-in
- Google and Discord users: sign in immediately
- Email/password users: sign in with the same password as before
- Spot-check that league ownership and commissioner access are intact
Step 6 — Remove Clerk credentials
Once everything is confirmed, remove from the production environment:
CLERK_SECRET_KEY
VITE_CLERK_PUBLISHABLE_KEY
CLERK_WEBHOOK_SECRET
DEV_ADMIN_CLERK_ID
Delete the Clerk webhook endpoint from the Clerk dashboard (it no longer exists in the codebase).
On the next deploy, scripts/migrate.mjs will log "CLERK_SECRET_KEY not set — skipping Clerk account migration" and skip gracefully.
Notes
Why email/password migration is a separate manual step
Clerk's REST API does not expose password hashes — they're only available in the CSV dashboard export. The automated deploy script (scripts/migrate.mjs) handles OAuth accounts via the API, but password hashes must be exported manually and migrated with scripts/migrate-clerk-passwords.mjs.
The bcrypt hashes from Clerk are directly compatible with BetterAuth — no re-hashing is needed. Users sign in with the same password as before.
What if a user signed up with both Google and email/password?
Both an OAuth account and a credential account will be created for them. BetterAuth handles this correctly — the user can sign in with either method.
Future cleanup
After the migration is stable (a week or two after production deploy), open a follow-up PR to:
- Remove the
clerk_idcolumn fromdatabase/schema.tsand runnpm run db:generate - Remove
CLERK_SECRET_KEYhandling fromscripts/migrate.mjs - Delete
scripts/migrate-clerk-passwords.mjs