Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/user/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
72 changes: 71 additions & 1 deletion packages/user/src/auth/better-auth/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,6 +8,25 @@ import { Pool } from "pg";

import type { BetterAuthConfig } from "../../types/config";

async function bcryptHash(password: string): Promise<string> {
return bcrypt.hashSync(password, 10); // 10 rounds - synchronous for simplicity
}

async function bcryptVerify({
password,
hash,
}: {
password: string;
hash: string;
}): Promise<boolean> {
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.
*
Expand Down Expand Up @@ -43,6 +63,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 }) => {
Expand All @@ -52,12 +99,24 @@ export function createAuth(config: BetterAuthConfig, connectionString: string) {
);
},
sendEmailVerificationOnSignUp: false,
password: {
hash: bcryptHash,
verify: bcryptVerify,
},
},

plugins: [
// 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}`);
Expand All @@ -70,7 +129,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",
},
},
},
}),
],
});
}
Expand Down
6 changes: 3 additions & 3 deletions packages/user/src/auth/better-auth/betterAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,8 @@ export class BetterAuthProvider implements AuthProvider {
async getUser(id: string): Promise<AuthUser | undefined> {
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}
`);

Expand All @@ -414,7 +414,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,
};
});
Expand Down
94 changes: 84 additions & 10 deletions packages/user/src/auth/better-auth/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,105 @@ 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<void> {
// 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,
role TEXT NOT NULL,
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();

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
`);
});
}
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading