diff --git a/packages/user/package.json b/packages/user/package.json index 123c81107..4cf795a21 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -32,6 +32,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { + "bcryptjs": "^2.4.3", "better-auth": "1.2.7", "humps": "2.0.1", "kysely": "^0.27.6", @@ -47,6 +48,7 @@ "@prefabs.tech/fastify-s3": "0.93.5", "@prefabs.tech/fastify-slonik": "0.93.5", "@prefabs.tech/tsconfig": "0.5.0", + "@types/bcrypt": "^6.0.0", "@types/humps": "2.0.6", "@types/node": "24.10.13", "@types/pg": "^8.20.0", diff --git a/packages/user/src/auth/better-auth/auth.ts b/packages/user/src/auth/better-auth/auth.ts index b77a73ea1..c06a93bbe 100644 --- a/packages/user/src/auth/better-auth/auth.ts +++ b/packages/user/src/auth/better-auth/auth.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcryptjs"; import { betterAuth } from "better-auth"; import { admin } from "better-auth/plugins/admin"; import { bearer } from "better-auth/plugins/bearer"; @@ -23,6 +24,26 @@ import type { BetterAuthConfig } from "../../types/config"; * - admin plugin required for revokeUserSessions(userId) * - passkey not in better-auth v1.x — tracked in findings.md */ +// Custom password hashing using bcrypt to support SuperTokens legacy passwords +async function bcryptHash(password: string): Promise { + return bcrypt.hashSync(password, 10); // 10 rounds - synchronous for simplicity +} + +async function bcryptVerify({ + password, + hash, +}: { + password: string; + hash: string; +}): Promise { + try { + return bcrypt.compareSync(password, hash); + } catch { + // If comparison fails due to invalid hash format, return false + return false; + } +} + export function createAuth(config: BetterAuthConfig, connectionString: string) { // Create a Kysely instance with a pg Pool that better-auth will use. // This satisfies better-auth's expectation for a Kysely adapter. @@ -31,10 +52,19 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { dialect: new PostgresDialect({ pool }), }); + // Determine the users table name from config, default to "users" + // Note: TABLE_USERS constant is "users" but config can override + // However, config.tables?.users?.name is not directly accessible here + // because BetterAuthConfig doesn't expose it. We'd need to pass it through. + // For now, we'll keep it simple and assume "users". + const usersTableName = "users"; // Could be made configurable in a future iteration + return betterAuth({ database: { db: db, type: "postgres", + // Use default casing (preserve field names as defined in schema) + // The account table uses camelCase column names: accountId, providerId, userId, createdAt, updatedAt }, basePath: config.routePrefix ?? "/auth", @@ -43,6 +73,14 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { trustedOrigins: config.trustedOrigins ?? [], + // Configure BetterAuth to use our existing 'users' table + user: { + // Use our existing table name instead of default "user" + modelName: usersTableName, + // Disable automatic table creation - we manage the table ourselves + disableMigrations: true, + }, + emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { @@ -52,14 +90,17 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { ); }, sendEmailVerificationOnSignUp: false, + // Use bcrypt for password hashing and verification to support SuperTokens legacy passwords + password: { + hash: bcryptHash, + verify: bcryptVerify, + }, }, plugins: [ - // POC assumption #4 — phone OTP capability bearer(), phoneNumber({ sendOTP: async ({ phoneNumber: phone, code }) => { - // POC: log to console. Phase 3: use SMS provider via fastify console.log(`[BetterAuth POC] OTP for ${phone}: ${code}`); }, expiresIn: 300, @@ -68,8 +109,6 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { getTempName: (phone) => phone, }, }), - - // Required for revokeAllSessions(userId) admin(), ], }); diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 76e680cc8..1498eb3ec 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -386,9 +386,10 @@ export class BetterAuthProvider implements AuthProvider { async getUser(id: string): Promise { return this.db.connect(async (connection) => { + // BetterAuth is configured to use 'users' table, so we query that const row = await connection.maybeOne(sql.unsafe` - SELECT id, email, ${sql.identifier(["emailVerified"])} - FROM "user" + SELECT id, email, email_verified + FROM "users" WHERE id = ${id} `); @@ -399,7 +400,7 @@ export class BetterAuthProvider implements AuthProvider { return { id: row.id as string, email: row.email as string, - emailVerified: row.emailVerified as boolean, + emailVerified: row.email_verified as boolean, roles, }; }); diff --git a/packages/user/src/auth/better-auth/migrate.ts b/packages/user/src/auth/better-auth/migrate.ts index 941712451..27f5ff80c 100644 --- a/packages/user/src/auth/better-auth/migrate.ts +++ b/packages/user/src/auth/better-auth/migrate.ts @@ -1,5 +1,5 @@ import { getMigrations } from "better-auth/db"; -import { sql, stringifyDsn } from "slonik"; +import { sql, stringifyDsn, type DatabasePoolConnection } from "slonik"; import { createAuth } from "./auth"; @@ -7,14 +7,18 @@ import type { BetterAuthConfig } from "../../types/config"; import type { Database, SlonikOptions } from "@prefabs.tech/fastify-slonik"; /** - * Run Better Auth migrations + create our own user_roles table via slonik. + * Run Better Auth migrations + migrate data from SuperTokens. * * Called from plugin.ts on startup when authProvider === "better-auth". - * Safe to call on every startup — all statements are CREATE IF NOT EXISTS. + * Safe to call on every startup — all statements are idempotent. * - * Better Auth manages its own tables (user, session, account, verification, - * phone_number) using its internal connection. Our user_roles table is - * created via slonik, the same connection pool used by the rest of the app. + * Better Auth manages its own tables (session, account, verification, phone_number) + * using its internal connection. The 'user' table is our existing 'users' table. + * + * The migration order: + * 1. Prepare users table schema (ensure columns exist and have correct types) + * 2. Migrate data from SuperTokens (if transitioning) + * 3. Run BetterAuth's own migrations */ export async function runBetterAuthMigrations( config: BetterAuthConfig, @@ -24,11 +28,10 @@ export async function runBetterAuthMigrations( // Better Auth tables — uses better-auth's own internal pg connection const connectionString = stringifyDsn(dbConfig.db); const auth = createAuth(config, connectionString); - const { runMigrations } = await getMigrations(auth.options); - await runMigrations(); - // user_roles table — provider-agnostic, managed via slonik + // Prepare the users table schema and migrate user data BEFORE BetterAuth runs await database.connect(async (connection) => { + // Ensure user_roles table exists await connection.query(sql.unsafe` CREATE TABLE IF NOT EXISTS user_roles ( user_id TEXT NOT NULL, @@ -36,5 +39,522 @@ export async function runBetterAuthMigrations( PRIMARY KEY (user_id, role) ) `); + + // Prepare users table for BetterAuth + await prepareUsersTable(connection); + + // Migrate user data from SuperTokens (user table only) - excludes account creation + await migrateUserDataFromSuperTokens(connection); }); + + // Now get the migration plan AFTER users table is prepared + const { runMigrations } = await getMigrations(auth.options); + + // Run BetterAuth migrations (session, account, verification tables, etc.) + // Note: 'user' table migrations are skipped due to disableMigrations: true in config + await runMigrations(); + + // After BetterAuth has created its tables (account, session, verification), migrate account data + await database.connect(async (connection) => { + await migrateAccountsFromSuperTokens(connection); + }); +} + +/** + * Prepare users table for BetterAuth compatibility. + * This runs on every startup (idempotent). + */ +async function prepareUsersTable( + connection: DatabasePoolConnection, +): Promise { + console.log("[BetterAuth] Preparing users table schema..."); + + // 1. Ensure name column is nullable (in case previous runs added NOT NULL) + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN name DROP NOT NULL`) + .catch(() => { + // Ignore if column doesn't exist or constraint doesn't exist + }); + + // 2. Change name column type from VARCHAR to TEXT for BetterAuth compatibility + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN name TYPE TEXT`) + .catch(() => { + // Ignore if already TEXT or if alteration fails + }); + + // 3. Change email column type from VARCHAR to TEXT for BetterAuth compatibility + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN email TYPE TEXT`) + .catch(() => { + // Ignore if already TEXT or if alteration fails + }); + + // 4. Populate name from email for any null/empty values + await connection.query( + sql.unsafe`UPDATE users SET name = email WHERE name IS NULL OR name = ''`, + ); + + // 4b. Ensure name is NOT NULL after population + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN name SET NOT NULL`) + .catch(() => { + // Ignore if already NOT NULL or column doesn't exist + }); + + // 5. Ensure email_verified is NOT NULL and set NULLs to FALSE + await connection.query( + sql.unsafe`UPDATE users SET email_verified = FALSE WHERE email_verified IS NULL`, + ); + + // 6. Add NOT NULL constraint on email_verified (if not already present) + await connection + .query( + sql.unsafe`ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL`, + ) + .catch(() => { + // Ignore if already NOT NULL or column doesn't exist + }); + + // 7. Ensure created_at is populated from signed_up_at where missing + await connection.query( + sql.unsafe`UPDATE users SET created_at = signed_up_at WHERE created_at IS NULL`, + ); + + // 8. Add NOT NULL constraint on created_at (since it has DEFAULT NOW()) + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN created_at SET NOT NULL`) + .catch(() => {}); + + // 9. Add NOT NULL constraint on updated_at (since it has DEFAULT NOW()) + await connection + .query(sql.unsafe`ALTER TABLE users ALTER COLUMN updated_at SET NOT NULL`) + .catch(() => {}); + + // 10. Set defaults for snake_case timestamp columns (keep in sync with camelCase) + await connection.query( + sql.unsafe` + ALTER TABLE users + ALTER COLUMN created_at SET DEFAULT NOW(), + ALTER COLUMN updated_at SET DEFAULT NOW(); + `, + ); + + // 11. Add BetterAuth's camelCase columns and sync them from snake_case + // BetterAuth expects camelCase column names regardless of casing option (which only affects table names) + // These must exist and have proper constraints to prevent migration errors. + // Note: passwordHash and passwordLastUpdatedAt are NOT needed—password is stored in account table. + await connection.query( + sql.unsafe` + ALTER TABLE users + ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN, + ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP, + ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP; + `, + ); + + // 12. Populate camelCase columns from their snake_case counterparts + await connection.query( + sql.unsafe` + UPDATE users SET + "emailVerified" = email_verified, + "createdAt" = created_at, + "updatedAt" = updated_at; + `, + ); + + // 13. Set NOT NULL constraints on required camelCase columns (emailVerified, createdAt, updatedAt) + await connection + .query( + sql.unsafe` + ALTER TABLE users + ALTER COLUMN "emailVerified" SET NOT NULL, + ALTER COLUMN "createdAt" SET NOT NULL, + ALTER COLUMN "updatedAt" SET NOT NULL; + `, + ) + .catch(() => { + /* ignore if already set */ + }); + + // 14. Set defaults for future inserts (camelCase columns) + await connection.query( + sql.unsafe` + ALTER TABLE users + ALTER COLUMN "emailVerified" SET DEFAULT FALSE, + ALTER COLUMN "createdAt" SET DEFAULT NOW(), + ALTER COLUMN "updatedAt" SET DEFAULT NOW(); + `, + ); + + console.log("[BetterAuth] Users table schema prepared"); +} + +/** + * One-time migration: Copy user data from SuperTokens tables to our users table. + * Only migrates email verification status. Password migration goes to account table. + */ +async function migrateUserDataFromSuperTokens( + connection: DatabasePoolConnection, +): Promise { + // Check if SuperTokens tables still exist (we're transitioning from SuperTokens) + const hasSuperTokens = await connection.maybeOne(sql.unsafe` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'st__emailpassword_users' + AND table_schema = 'public' + ) AS exists + `); + + if (!hasSuperTokens?.exists) { + console.log( + "[BetterAuth] SuperTokens tables not found - skipping user data migration (fresh installation)", + ); + return; + } + + // Check if any users have not yet been verified (exist in st__emailverification_verified_emails) + const needsMigration = await connection.maybeOne(sql.unsafe` + SELECT EXISTS( + SELECT 1 + FROM users u + JOIN st__emailverification_verified_emails st ON u.id = st.user_id + WHERE u.email_verified = FALSE + LIMIT 1 + ) AS has_missing + `); + + if (!needsMigration?.has_missing) { + console.log( + "[BetterAuth] All users already verified - skipping user data migration", + ); + return; + } + + console.log( + "[BetterAuth] Migrating email verification status from SuperTokens...", + ); + + await connection.transaction(async (tx: DatabasePoolConnection) => { + // Set email_verified = TRUE for users in st__emailverification_verified_emails + await tx.query(sql.unsafe` + UPDATE users u + SET + email_verified = TRUE, + updated_at = NOW() + FROM st__emailverification_verified_emails st + WHERE u.id = st.user_id + AND u.email_verified = FALSE + `); + + // Log migration statistics + const stats = await tx.one(sql.unsafe` + SELECT + COUNT(*) AS total_users, + COUNT(*) FILTER (WHERE email_verified = TRUE) AS verified_users + FROM users + `); + console.log("[BetterAuth] User data migration statistics:", stats); + }); + + console.log("[BetterAuth] SuperTokens user data migration completed"); +} + +/** + * One-time migration: Create BetterAuth account records for existing email/password users. + * This must run AFTER BetterAuth has created the account table. + */ +async function migrateAccountsFromSuperTokens( + connection: DatabasePoolConnection, +): Promise { + console.log( + "[BetterAuth] ===== Starting account migration from SuperTokens =====", + ); + + // Check if SuperTokens tables exist (only run if transitioning) + const hasSuperTokens = await connection.maybeOne(sql.unsafe` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'st__emailpassword_users' + AND table_schema = 'public' + ) AS exists + `); + + if (!hasSuperTokens?.exists) { + console.log( + "[BetterAuth] SuperTokens tables not found - skipping account migration (fresh installation)", + ); + return; + } + + console.log("[BetterAuth] ✓ SuperTokens tables found"); + + // Diagnostic: Count users in st__emailpassword_users + const stCount = (await connection + .maybeOne( + sql.unsafe` + SELECT COUNT(*) as count FROM st__emailpassword_users + `, + ) + .catch(() => ({ count: 0 }))) as { count: number }; + console.log( + `[BetterAuth] st__emailpassword_users has ${stCount.count} records`, + ); + + if (stCount.count === 0) { + console.log("[BetterAuth] No SuperTokens users to migrate"); + return; + } + + // Diagnostic: Show sample st__emailpassword_users data + const sampleSt = (await connection + .many( + sql.unsafe` + SELECT user_id, email, length(password_hash) as pwd_len, time_joined + FROM st__emailpassword_users + LIMIT 3 + `, + ) + .catch(() => [])) as { + user_id: string; + email: string; + pwd_len: number; + time_joined: string; + }[]; + console.log( + "[BetterAuth] Sample st__emailpassword_users:", + JSON.stringify(sampleSt, undefined, 2), + ); + + // Diagnostic: Check which of those users exist in users table + if (sampleSt.length > 0) { + const sampleIds = sampleSt.map((s) => `'${s.user_id}'`).join(","); + const userExists = (await connection + .many( + sql.unsafe` + SELECT id, email FROM users WHERE id IN (${sampleIds}) + `, + ) + .catch(() => [])) as { id: string; email: string }[]; + console.log( + "[BetterAuth] Matching users in users table:", + JSON.stringify(userExists, undefined, 2), + ); + } + + // Diagnostic: Check account table structure + try { + const tableInfo = (await connection + .maybeOne( + sql.unsafe` + SELECT column_name, is_nullable, data_type + FROM information_schema.columns + WHERE table_name = 'account' + ORDER BY ordinal_position + `, + ) + .catch(() => {})) as + | { column_name: string; is_nullable: string; data_type: string } + | undefined; + + if (tableInfo) { + console.log( + "[BetterAuth] Account table columns:", + JSON.stringify(tableInfo, undefined, 2), + ); + } + } catch (error) { + console.log("[BetterAuth] Could not fetch account table structure:", error); + } + + // Check which accounts already exist + const existingAccountUsers = (await connection + .many( + sql.unsafe` + SELECT "userId", "providerId" FROM "account" WHERE "providerId" = 'email' LIMIT 5 + `, + ) + .catch(() => [])) as { userId: string; providerId: string }[]; + console.log( + "[BetterAuth] Existing email accounts (sample):", + JSON.stringify(existingAccountUsers, undefined, 2), + ); + + // Find users that need account migration (matching logic) + const usersToMigrate = (await connection + .many( + sql.unsafe` + SELECT st.user_id, st.email, length(st.password_hash) as pwd_len + FROM st__emailpassword_users st + WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = st.user_id) + AND st.user_id IS NOT NULL + AND st.email IS NOT NULL + AND st.password_hash IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "account" a + WHERE a."userId" = st.user_id AND a."providerId" = 'email' + ) + LIMIT 5 + `, + ) + .catch(() => [])) as { user_id: string; email: string; pwd_len: number }[]; + + console.log( + `[BetterAuth] Users that need account migration: ${usersToMigrate.length}`, + ); + if (usersToMigrate.length > 0) { + console.log( + "[BetterAuth] Sample users to migrate:", + JSON.stringify(usersToMigrate, undefined, 2), + ); + } + + if (usersToMigrate.length === 0) { + console.log("[BetterAuth] No accounts need migration - all already exist"); + return; + } + + // Diagnostic: Check account table schema + try { + const tableExists = await connection.maybeOne(sql.unsafe` + SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'account' AND table_schema = 'public') AS exists + `); + console.log("[BetterAuth] Account table exists:", tableExists?.exists); + + if (tableExists?.exists) { + const columns = (await connection.many(sql.unsafe` + SELECT column_name, is_nullable, column_default, data_type + FROM information_schema.columns + WHERE table_name = 'account' AND table_schema = 'public' + ORDER BY ordinal_position + `)) as { + column_name: string; + is_nullable: string; + column_default: string | null; + data_type: string; + }[]; + console.log( + "[BetterAuth] Account table columns raw:", + JSON.stringify(columns, undefined, 2), + ); + + // List NOT NULL columns + const notNullCols = (await connection.many(sql.unsafe` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'account' AND table_schema = 'public' AND is_nullable = 'NO' + `)) as { column_name: string }[]; + console.log( + "[BetterAuth] NOT NULL columns:", + JSON.stringify(notNullCols, undefined, 2), + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log("[BetterAuth] Schema check failed:", message); + } + + console.log("[BetterAuth] Executing INSERT to create account records..."); + console.log( + "[BetterAuth] About to insert accounts for users:", + JSON.stringify(usersToMigrate, undefined, 2), + ); + + // Create account entries by reading directly from SuperTokens table + // Include: id (primary key), userId, providerId, accountId (email), password, scope, createdAt, updatedAt + // All NOT NULL columns are covered. + // IMPORTANT: BetterAuth's email/password provider uses providerId = 'credential', not 'email' + const result = await connection + .query( + sql.unsafe` + INSERT INTO "account" ("id", "userId", "providerId", "accountId", "password", "scope", "createdAt", "updatedAt") + SELECT + gen_random_uuid() AS "id", + st.user_id AS "userId", + 'credential' AS "providerId", + st.email AS "accountId", + st.password_hash AS "password", + '' AS "scope", + NOW() AS "createdAt", + NOW() AS "updatedAt" + FROM st__emailpassword_users st + WHERE EXISTS ( + SELECT 1 FROM users u WHERE u.id = st.user_id + ) + AND st.user_id IS NOT NULL + AND st.email IS NOT NULL + AND st.password_hash IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "account" a + WHERE a."userId" = st.user_id AND a."providerId" = 'credential' + ) + RETURNING * + `, + ) + .catch((error: unknown) => { + const err = error as { + message: string; + detail?: string; + hint?: string; + code?: string; + sqlState?: string; + }; + console.error("[BetterAuth] ERROR during account insert:", { + message: err.message, + detail: err.detail, + hint: err.hint, + code: err.code, + sqlState: err.sqlState, + }); + // Also log what we tried to insert + console.error( + "[BetterAuth] Failed to insert accounts for users:", + usersToMigrate, + ); + throw error; + }); + + console.log( + `[BetterAuth] ✓ Successfully created ${result.rowCount} account records for email/password users`, + ); + + console.log( + `[BetterAuth] ✓ Successfully created ${result.rowCount} account records for email/password users`, + ); + + // Show what we inserted (sample) + if (result.rowCount > 0) { + const insertedAccounts = (await connection + .many( + sql.unsafe` + SELECT "id", "userId", "providerId", "accountId", length("password") as pwd_len + FROM "account" + WHERE "providerId" = 'email' + ORDER BY "createdAt" DESC + LIMIT 5 + `, + ) + .catch(() => [])) as { + id: string; + userId: string; + providerId: string; + accountId: string; + pwd_len: number; + }[]; + console.log( + "[BetterAuth] Recently inserted/updated accounts:", + JSON.stringify(insertedAccounts, undefined, 2), + ); + } + + // Verify final count + const finalCount = (await connection + .maybeOne( + sql.unsafe` + SELECT COUNT(*) as count FROM "account" WHERE "providerId" = 'email' + `, + ) + .catch(() => ({ count: 0 }))) as { count: number }; + console.log(`[BetterAuth] Total email accounts now: ${finalCount.count}`); } diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index a47407636..544349831 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -1,9 +1,18 @@ import FastifyPlugin from "fastify-plugin"; +import { ROLE_USER } from "../../constants"; + import type { AppError } from "../authProvider"; import type { BetterAuthProvider } from "./betterAuthProvider"; import type { FastifyInstance } from "fastify"; +interface AuthErrorInfo { + name?: string; + message?: string; + status?: number; + body?: unknown; +} + /** * POC test routes — registered only when authProvider === "better-auth". * @@ -34,7 +43,7 @@ const pocRoutes = async ( // Create user const user = await auth.signUp(req.body.email, req.body.password); // Assign default role - await auth.assignRoles(user.id, ["ROLE_USER"]); + await auth.assignRoles(user.id, [ROLE_USER]); // Sign in to get token (so we can set cookie and return token) const result = await auth.signIn(req.body.email, req.body.password); // Set session cookie @@ -45,6 +54,18 @@ const pocRoutes = async ( .code(201) .send({ ok: true, user: result.user, token: result.token }); } catch (error) { + // Log raw error for debugging + const err = error as AuthErrorInfo; + fastify.log.error( + { + err, + errName: err?.name, + errMessage: err?.message, + errStatus: err?.status, + errBody: err?.body, + }, + "Sign-up error raw", + ); const appError = auth.normalizeError(error); return reply.code(appError.statusCode).send({ error: appError }); } @@ -67,6 +88,18 @@ const pocRoutes = async ( reply.header("Set-Cookie", cookie); return reply.send({ ok: true, user: result.user, token: result.token }); } catch (error) { + // Log raw error for debugging + const err = error as AuthErrorInfo; + fastify.log.error( + { + err, + errName: err?.name, + errMessage: err?.message, + errStatus: err?.status, + errBody: err?.body, + }, + "Sign-in error raw", + ); const appError = auth.normalizeError(error); return reply.code(appError.statusCode).send({ error: appError }); } diff --git a/packages/user/src/migrations/queries.ts b/packages/user/src/migrations/queries.ts index b493132ef..5badf24d4 100644 --- a/packages/user/src/migrations/queries.ts +++ b/packages/user/src/migrations/queries.ts @@ -42,12 +42,19 @@ const createUsersTableQuery = ( return sql.unsafe` CREATE TABLE IF NOT EXISTS ${sql.identifier([users])} ( id VARCHAR ( 36 ) PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password_hash VARCHAR(256), + password_last_updated_at TIMESTAMP, disabled BOOLEAN NOT NULL DEFAULT false, - email VARCHAR ( 256 ) NOT NULL, photo_id INTEGER, last_login_at TIMESTAMP NOT NULL DEFAULT NOW(), signed_up_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), deleted_at TIMESTAMP, + image TEXT, FOREIGN KEY ( photo_id ) REFERENCES ${sql.identifier([ config.s3?.table?.name || TABLE_FILES, ])} ( id ) @@ -55,4 +62,67 @@ const createUsersTableQuery = ( `; }; -export { createInvitationsTableQuery, createUsersTableQuery }; +/** + * Returns multiple queries to be executed sequentially. + * Each query cleans up and ensures proper constraints for BetterAuth columns. + */ +const addBetterAuthColumnsQuery = ( + config: ApiConfig, +): QuerySqlToken[] => { + const users = config.user.tables?.users?.name || TABLE_USERS; + + return [ + // 1. Add email_verified column if it doesn't exist (nullable initially) + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS email_verified BOOLEAN; + `, + // 2. Set NULLs to FALSE + sql.unsafe` + UPDATE ${sql.identifier([users])} + SET email_verified = FALSE + WHERE email_verified IS NULL; + `, + // 3. Add NOT NULL constraint (idempotent - safe if already NOT NULL) + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ALTER COLUMN email_verified SET NOT NULL; + `, + // 4. Set default for future inserts + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ALTER COLUMN email_verified SET DEFAULT FALSE; + `, + // 5. Add other columns if they don't exist + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS name VARCHAR(255); + `, + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS password_hash VARCHAR(256); + `, + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS password_last_updated_at TIMESTAMP; + `, + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT NOW(); + `, + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT NOW(); + `, + sql.unsafe` + ALTER TABLE ${sql.identifier([users])} + ADD COLUMN IF NOT EXISTS image TEXT; + `, + ]; +}; + +export { + addBetterAuthColumnsQuery, + createInvitationsTableQuery, + createUsersTableQuery, +}; diff --git a/packages/user/src/migrations/runMigrations.ts b/packages/user/src/migrations/runMigrations.ts index af45207d6..72daf0129 100644 --- a/packages/user/src/migrations/runMigrations.ts +++ b/packages/user/src/migrations/runMigrations.ts @@ -1,4 +1,8 @@ -import { createInvitationsTableQuery, createUsersTableQuery } from "./queries"; +import { + addBetterAuthColumnsQuery, + createInvitationsTableQuery, + createUsersTableQuery, +} from "./queries"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; @@ -8,6 +12,11 @@ const runMigrations = async (config: ApiConfig, database: Database) => { await connection.transaction(async (transactionConnection) => { await transactionConnection.query(createUsersTableQuery(config)); await transactionConnection.query(createInvitationsTableQuery(config)); + // Add BetterAuth-required columns to users table (may return array of queries) + const betterAuthQueries = addBetterAuthColumnsQuery(config); + for (const query of betterAuthQueries) { + await transactionConnection.query(query); + } }); }); }; diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index 9889f2157..6cefeb7c3 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -40,7 +40,10 @@ const userPlugin: FastifyPluginAsync = async (fastify) => { const authProvider = new BetterAuthProvider(user.betterAuth, dbConfig); - // Run Better Auth migrations + ensure user_roles table exists (via slonik) + // Run general migrations FIRST to add BetterAuth columns to users table + await runMigrations(fastify.config, fastify.slonik); + + // Then run BetterAuth-specific migrations and data migration const db: Database = fastify.slonik; await runBetterAuthMigrations(user.betterAuth, dbConfig, db); @@ -61,9 +64,10 @@ const userPlugin: FastifyPluginAsync = async (fastify) => { fastify.addHook("onReady", async () => { await seedRoles(user); }); - } - await runMigrations(fastify.config, fastify.slonik); + // Run general migrations for SuperTokens path + await runMigrations(fastify.config, fastify.slonik); + } fastify.decorate("hasPermission", hasPermission); diff --git a/packages/user/src/types/bcryptjs.d.ts b/packages/user/src/types/bcryptjs.d.ts new file mode 100644 index 000000000..069414f1c --- /dev/null +++ b/packages/user/src/types/bcryptjs.d.ts @@ -0,0 +1,7 @@ +declare module "bcryptjs" { + function hashSync(password: string, saltRounds: number): string; + function compareSync(password: string, hash: string): boolean; + function hash(password: string, saltRounds: number): Promise; + function compare(password: string, hash: string): Promise; + export = { hashSync, compareSync, hash, compare }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8a9a57f8..cfeb29b37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: packages/user: dependencies: + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 better-auth: specifier: 1.2.7 version: 1.2.7 @@ -553,6 +556,9 @@ importers: '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/humps': specifier: 2.0.6 version: 2.0.6 @@ -2076,6 +2082,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2579,6 +2588,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} @@ -7977,6 +7989,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 24.10.13 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -8533,6 +8549,8 @@ snapshots: baseline-browser-mapping@2.9.19: {} + bcryptjs@2.4.3: {} + before-after-hook@3.0.2: {} better-auth@1.2.7: