From 9d3ac252d2921cbe2d2aebb640ba4d82f2c6a36f Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Fri, 27 Mar 2026 15:37:39 +0545 Subject: [PATCH 1/3] feat(poc): migrate better-auth user data --- packages/user/src/auth/better-auth/auth.ts | 48 +++++++++++++- .../auth/better-auth/betterAuthProvider.ts | 6 +- packages/user/src/auth/better-auth/migrate.ts | 65 ++++++++++++++++--- .../user/src/auth/better-auth/pocRoutes.ts | 4 +- 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/packages/user/src/auth/better-auth/auth.ts b/packages/user/src/auth/better-auth/auth.ts index b77a73ea1..f49d35806 100644 --- a/packages/user/src/auth/better-auth/auth.ts +++ b/packages/user/src/auth/better-auth/auth.ts @@ -43,6 +43,33 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { trustedOrigins: config.trustedOrigins ?? [], + user: { + // Use our existing table name instead of default "user" + modelName: "users", + // Disable automatic table creation - we manage the table ourselves + disableMigrations: true, + fields: { + createdAt: "signed_up_at", + deletedAt: "deleted_at", + emailVerified: "email_verified", + emailVerifiedAt: "email_verified_at", + updatedAt: "updated_at", + name: "given_name", + phoneNumber: "phone_number", + phoneNumberVerified: "phone_number_verified", + lastLoginAt: "last_login_at", + }, + additionalFields: { + disabled: { type: "boolean", fieldName: "disabled", required: false }, + middleNames: { + type: "string", + fieldName: "middle_names", + required: false, + }, + photoId: { type: "number", fieldName: "photo_id", required: false }, + surname: { type: "string", fieldName: "surname", required: false }, + }, + }, emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { @@ -58,6 +85,14 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { // POC assumption #4 — phone OTP capability bearer(), phoneNumber({ + schema: { + user: { + fields: { + phoneNumber: "phone_number", + phoneNumberVerified: "phone_number_verified", + }, + }, + }, sendOTP: async ({ phoneNumber: phone, code }) => { // POC: log to console. Phase 3: use SMS provider via fastify console.log(`[BetterAuth POC] OTP for ${phone}: ${code}`); @@ -70,7 +105,18 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { }), // Required for revokeAllSessions(userId) - admin(), + admin({ + schema: { + user: { + fields: { + role: "role", + banned: "banned", + banReason: "ban_reason", + banExpires: "ban_expires", + }, + }, + }, + }), ], }); } diff --git a/packages/user/src/auth/better-auth/betterAuthProvider.ts b/packages/user/src/auth/better-auth/betterAuthProvider.ts index 76e680cc8..d7851e5c3 100644 --- a/packages/user/src/auth/better-auth/betterAuthProvider.ts +++ b/packages/user/src/auth/better-auth/betterAuthProvider.ts @@ -387,8 +387,8 @@ export class BetterAuthProvider implements AuthProvider { async getUser(id: string): Promise { return this.db.connect(async (connection) => { const row = await connection.maybeOne(sql.unsafe` - SELECT id, email, ${sql.identifier(["emailVerified"])} - FROM "user" + SELECT id, email, ${sql.identifier(["email_verified"])} + FROM "users" WHERE id = ${id} `); @@ -399,7 +399,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..e0709316a 100644 --- a/packages/user/src/auth/better-auth/migrate.ts +++ b/packages/user/src/auth/better-auth/migrate.ts @@ -10,25 +10,21 @@ import type { Database, SlonikOptions } from "@prefabs.tech/fastify-slonik"; * Run Better Auth migrations + create our own user_roles table via slonik. * * 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 CREATE IF NOT EXISTS + * or ADD COLUMN IF NOT EXISTS. * * 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. + * phone_number) using its internal connection. Our user_roles table and + * extra columns on the users table are managed via slonik. */ export async function runBetterAuthMigrations( config: BetterAuthConfig, dbConfig: SlonikOptions, database: Database, ): Promise { - // 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 + // First, prepare the users table using slonik connection await database.connect(async (connection) => { + // user_roles table — provider-agnostic, managed via slonik await connection.query(sql.unsafe` CREATE TABLE IF NOT EXISTS user_roles ( user_id TEXT NOT NULL, @@ -36,5 +32,54 @@ export async function runBetterAuthMigrations( PRIMARY KEY (user_id, role) ) `); + + // Add missing columns one at a time (Slonik doesn't allow multiple statements) + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS given_name TEXT + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS middle_names TEXT + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS surname TEXT + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW() + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number TEXT + `); + await connection.query(sql.unsafe` + ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number_verified BOOLEAN DEFAULT FALSE + `); + + // Fix any existing NULL values in email_verified BEFORE Better Auth runs + await connection.query(sql.unsafe` + UPDATE users SET email_verified = false WHERE email_verified IS NULL + `); + + // Ensure email_verified is NOT NULL (Better Auth requirement) + await connection.query(sql.unsafe` + DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'email_verified' + ) THEN + UPDATE users SET email_verified = false WHERE email_verified IS NULL; + ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL; + ALTER TABLE users ALTER COLUMN email_verified SET DEFAULT false; + END IF; + END $$; + `); }); + + // Run Better Auth migrations with its own internal connection + // NOW our fixes are already in place + const connectionString = stringifyDsn(dbConfig.db); + const auth = createAuth(config, connectionString); + const { runMigrations } = await getMigrations(auth.options); + await runMigrations(); } diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index a47407636..5b908c44d 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -1,5 +1,7 @@ import FastifyPlugin from "fastify-plugin"; +import { ROLE_USER } from "src/constants"; + import type { AppError } from "../authProvider"; import type { BetterAuthProvider } from "./betterAuthProvider"; import type { FastifyInstance } from "fastify"; @@ -34,7 +36,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 From 9c323a16195ff4a927a9886bdffc614f243d0687 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Fri, 27 Mar 2026 16:36:17 +0545 Subject: [PATCH 2/3] chore(poc): update role from constant --- packages/user/src/auth/better-auth/pocRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/user/src/auth/better-auth/pocRoutes.ts b/packages/user/src/auth/better-auth/pocRoutes.ts index 5b908c44d..b0c4491ed 100644 --- a/packages/user/src/auth/better-auth/pocRoutes.ts +++ b/packages/user/src/auth/better-auth/pocRoutes.ts @@ -1,6 +1,6 @@ import FastifyPlugin from "fastify-plugin"; -import { ROLE_USER } from "src/constants"; +import { ROLE_USER } from "../../constants"; import type { AppError } from "../authProvider"; import type { BetterAuthProvider } from "./betterAuthProvider"; From 06da2ce10c90dce910ccf4a31a89103c04e46394 Mon Sep 17 00:00:00 2001 From: kabin thakuri Date: Fri, 27 Mar 2026 19:16:16 +0545 Subject: [PATCH 3/3] refactor(poc): update migrate script to migrate existing data --- packages/user/package.json | 2 ++ packages/user/src/auth/better-auth/auth.ts | 24 +++++++++++++++ packages/user/src/auth/better-auth/migrate.ts | 29 +++++++++++++++++++ pnpm-lock.yaml | 20 +++++++++++++ 4 files changed, 75 insertions(+) diff --git a/packages/user/package.json b/packages/user/package.json index 123c81107..03521ba96 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": "3.0.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/bcryptjs": "3.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 f49d35806..b6fa08a91 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"; @@ -7,6 +8,25 @@ import { Pool } from "pg"; import type { BetterAuthConfig } from "../../types/config"; +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; + } +} + /** * Creates the Better Auth instance from config. * @@ -79,6 +99,10 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) { ); }, sendEmailVerificationOnSignUp: false, + password: { + hash: bcryptHash, + verify: bcryptVerify, + }, }, plugins: [ diff --git a/packages/user/src/auth/better-auth/migrate.ts b/packages/user/src/auth/better-auth/migrate.ts index e0709316a..b2c4cb7d2 100644 --- a/packages/user/src/auth/better-auth/migrate.ts +++ b/packages/user/src/auth/better-auth/migrate.ts @@ -82,4 +82,33 @@ export async function runBetterAuthMigrations( const auth = createAuth(config, connectionString); const { runMigrations } = await getMigrations(auth.options); await runMigrations(); + + await database.connect(async (connection) => { + await connection.query(sql.unsafe` + INSERT INTO account (id, "userId", "accountId", "providerId", password, "createdAt", "updatedAt") + SELECT + user_id, + user_id, + user_id, + 'credential', + password_hash, + to_timestamp(time_joined / 1000.0), + to_timestamp(time_joined / 1000.0) -- updatedAt = createdAt for migrated data + FROM st__emailpassword_users + ON CONFLICT (id) DO NOTHING + `); + + await connection.query(sql.unsafe` + INSERT INTO account (id, "userId", "accountId", "providerId", "createdAt", "updatedAt") + SELECT + user_id, + user_id, + third_party_user_id, + third_party_id, + to_timestamp(time_joined / 1000.0), + to_timestamp(time_joined / 1000.0) + FROM st__thirdparty_users + ON CONFLICT (id) DO NOTHING + `); + }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8a9a57f8..8d53b135a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,9 @@ importers: packages/user: dependencies: + bcryptjs: + specifier: 3.0.3 + version: 3.0.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/bcryptjs': + specifier: 3.0.0 + version: 3.0.0 '@types/humps': specifier: 2.0.6 version: 2.0.6 @@ -2076,6 +2082,10 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2579,6 +2589,10 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + before-after-hook@3.0.2: resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} @@ -7977,6 +7991,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -8533,6 +8551,8 @@ snapshots: baseline-browser-mapping@2.9.19: {} + bcryptjs@3.0.3: {} + before-after-hook@3.0.2: {} better-auth@1.2.7: