* 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>
196 lines
6.3 KiB
JavaScript
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`);
|
|
}
|
|
}
|