* 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>
143 lines
4.2 KiB
JavaScript
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.");
|
|
}
|