brackt/scripts/migrate.mjs
Chris Parsons ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* 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>
2026-04-24 22:00:49 -07:00

196 lines
6.3 KiB
JavaScript

import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const migrationUrl = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
if (!migrationUrl) {
console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required");
process.exit(1);
}
console.log("Running database migrations...");
const client = postgres(migrationUrl, {
max: 1,
onnotice: () => {}, // suppress NOTICE messages (schema/table already exists, etc.)
});
try {
const db = drizzle(client);
await migrate(db, { migrationsFolder: path.resolve(__dirname, "../drizzle") });
console.log("Migrations completed successfully");
} catch (err) {
console.error("Migration failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
// ── Clerk → BetterAuth one-time data migration ────────────────────────────────
// Runs on every deploy but is fully idempotent. Skips automatically once
// CLERK_SECRET_KEY is removed from the environment.
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
if (!CLERK_SECRET_KEY) {
console.log("CLERK_SECRET_KEY not set — skipping Clerk account migration");
} else {
console.log("Running Clerk account migration...");
try {
await runClerkMigration(client, CLERK_SECRET_KEY);
console.log("Clerk account migration complete");
} catch (err) {
console.error("Clerk account migration failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
}
await client.end().catch(() => {});
process.exit(0);
// ─────────────────────────────────────────────────────────────────────────────
async function runClerkMigration(sql, secretKey) {
// 1. Convert teams.owner_id from Clerk IDs to users.id UUIDs
// Safe to run repeatedly — WHERE clause only matches unconverted rows.
const teamsResult = await sql`
UPDATE teams t
SET owner_id = u.id::text
FROM users u
WHERE t.owner_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` teams.owner_id: ${teamsResult.count} rows converted`);
// 2. Convert commissioners.user_id from Clerk IDs to users.id UUIDs
const commissResult = await sql`
UPDATE commissioners c
SET user_id = u.id::text
FROM users u
WHERE c.user_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` commissioners.user_id: ${commissResult.count} rows converted`);
// 3. Mark all Clerk users as email-verified (they already verified via Clerk)
await sql`
UPDATE users
SET email_verified = true
WHERE clerk_id IS NOT NULL
AND email_verified = false
`;
// 4. Migrate OAuth accounts from Clerk into BetterAuth's accounts table.
// Skip entirely if any OAuth accounts already exist to avoid duplicates.
// We check only OAuth providers (not 'credential') so running migrate-clerk-passwords.mjs
// first doesn't prevent OAuth accounts from being created.
const [{ count: existingCount }] = await sql`
SELECT COUNT(*)::int AS count FROM accounts WHERE provider_id IN ('google', 'discord')
`;
if (existingCount > 0) {
console.log(` ${existingCount} OAuth accounts already exist — skipping Clerk account import`);
return;
}
// Fetch all users with Clerk IDs from our DB
const dbUsers = await sql`
SELECT id, clerk_id FROM users WHERE clerk_id IS NOT NULL
`;
if (dbUsers.length === 0) {
console.log(" No Clerk users found in DB — nothing to migrate");
return;
}
console.log(` Found ${dbUsers.length} Clerk users in DB, fetching from Clerk API...`);
const dbUserByClerkId = new Map(dbUsers.map((u) => [u.clerk_id, u.id]));
// Fetch all users from Clerk API (paginated)
const clerkUsers = [];
let offset = 0;
const limit = 100;
while (true) {
const res = await fetch(
`https://api.clerk.com/v1/users?limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${secretKey}` } }
);
if (!res.ok) {
throw new Error(`Clerk API error ${res.status}: ${await res.text()}`);
}
const batch = await res.json();
clerkUsers.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
console.log(` Fetched ${clerkUsers.length} users from Clerk API`);
let googleCount = 0;
let discordCount = 0;
let emailPasswordCount = 0;
let skippedCount = 0;
for (const clerkUser of clerkUsers) {
const userId = dbUserByClerkId.get(clerkUser.id);
if (!userId) {
skippedCount++;
continue;
}
for (const ext of clerkUser.external_accounts ?? []) {
if (ext.provider === "google") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'google',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'google'
)
`;
googleCount++;
}
if (ext.provider === "discord") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'discord',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'discord'
)
`;
discordCount++;
}
}
if (clerkUser.password_enabled) {
emailPasswordCount++;
}
}
console.log(
` Accounts created: ${googleCount} Google, ${discordCount} Discord`
);
if (emailPasswordCount > 0) {
console.log(
` ${emailPasswordCount} email/password users — they must use "Forgot Password" on first login`
);
}
if (skippedCount > 0) {
console.log(` Skipped ${skippedCount} Clerk users with no matching DB row`);
}
}