brackt/scripts/migrate-clerk-passwords.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

143 lines
4.2 KiB
JavaScript

/**
* One-time script: migrates email/password hashes from a Clerk CSV export into
* BetterAuth's accounts table.
*
* Usage:
* DATABASE_URL=... CLERK_SECRET_KEY=... node scripts/migrate-clerk-passwords.mjs
*
* Before running:
* 1. Go to Clerk dashboard → Users → Export → Download CSV
* 2. Save the file as exported_users.csv in the project root
*
* The script is idempotent — safe to run multiple times.
* Delete exported_users.csv after confirming the migration succeeded.
*/
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
import postgres from "postgres";
const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required");
process.exit(1);
}
const CSV_PATH = resolve(process.cwd(), "exported_users.csv");
if (!existsSync(CSV_PATH)) {
console.error(`ERROR: exported_users.csv not found at ${CSV_PATH}`);
console.error("Export users from the Clerk dashboard (Users → Export) and save as exported_users.csv");
process.exit(1);
}
const sql = postgres(DATABASE_URL, { max: 1 });
try {
await migratePasswords();
} catch (err) {
console.error("Migration failed:", err);
await sql.end().catch(() => {});
process.exit(1);
}
await sql.end().catch(() => {});
process.exit(0);
// ─────────────────────────────────────────────────────────────────────────────
function parseCSV(content) {
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, ""));
return lines.slice(1).map((line) => {
// Handle quoted fields that may contain commas
const values = [];
let current = "";
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
values.push(current.trim());
current = "";
} else {
current += char;
}
}
values.push(current.trim());
return headers.reduce((obj, header, i) => {
obj[header] = (values[i] ?? "").replace(/^"|"$/g, "");
return obj;
}, {});
});
}
async function migratePasswords() {
const csvContent = readFileSync(CSV_PATH, "utf-8");
const rows = parseCSV(csvContent);
console.log(`Parsed ${rows.length} users from CSV`);
// Only process rows with a password hash
const passwordRows = rows.filter((r) => r.password_digest && r.password_hasher === "bcrypt");
console.log(`Found ${passwordRows.length} users with bcrypt password hashes`);
if (passwordRows.length === 0) {
console.log("Nothing to migrate.");
return;
}
let created = 0;
let skipped = 0;
let notFound = 0;
for (const row of passwordRows) {
const clerkId = row.id;
const passwordHash = row.password_digest;
// Find the user in our DB by their Clerk ID
const [dbUser] = await sql`
SELECT id FROM users WHERE clerk_id = ${clerkId} LIMIT 1
`;
if (!dbUser) {
notFound++;
continue;
}
// Check if a credential account already exists for this user
const [existing] = await sql`
SELECT 1 FROM accounts
WHERE user_id = ${dbUser.id}::uuid AND provider_id = 'credential'
LIMIT 1
`;
if (existing) {
skipped++;
continue;
}
// Insert credential account with the migrated bcrypt hash
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
VALUES (
gen_random_uuid()::text,
${dbUser.id},
'credential',
${dbUser.id}::uuid,
${passwordHash},
now(),
now()
)
`;
created++;
}
console.log(`Done: ${created} credential accounts created, ${skipped} already existed, ${notFound} Clerk users not found in DB`);
if (notFound > 0) {
console.log(" (notFound users may have been deleted or never synced via webhook)");
}
console.log("\nIMPORTANT: Delete exported_users.csv now — it contains password hashes.");
}