From da08b014aed314f5a209e4e656019e1d1743bba3 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sun, 12 Apr 2026 21:20:13 -0400 Subject: [PATCH] feat(auth): add better-auth username plugin (#279) * feat(auth): add better-auth username plugin Enable username-based registration and sign-in by adding the username plugin to both server and client auth configurations. Adds username and display_username columns to the user table via migration. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-Authored-By: Claude Opus 4.6 * test(auth): add username plugin tests and fix test setup Add schema and integration tests for the username plugin. Fix the in-memory SQLite test setup to include username columns so existing auth tests don't break. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Bob Co-authored-by: Claude Opus 4.6 --- migrations/0004_username_plugin.sql | 4 ++ migrations/meta/_journal.json | 7 +++ server/auth.ts | 4 +- server/db/auth-schema.test.ts | 44 +++++++++++++ server/db/auth-schema.ts | 2 + server/routes/auth-username.test.ts | 97 +++++++++++++++++++++++++++++ server/test/setup.ts | 2 + src/lib/auth-client.ts | 2 + 8 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 migrations/0004_username_plugin.sql create mode 100644 server/db/auth-schema.test.ts create mode 100644 server/routes/auth-username.test.ts diff --git a/migrations/0004_username_plugin.sql b/migrations/0004_username_plugin.sql new file mode 100644 index 00000000..0f831933 --- /dev/null +++ b/migrations/0004_username_plugin.sql @@ -0,0 +1,4 @@ +-- Add username columns for better-auth username plugin +ALTER TABLE `user` ADD `username` text;--> statement-breakpoint +ALTER TABLE `user` ADD `display_username` text;--> statement-breakpoint +CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index ba818f38..2edf1b35 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1775090000000, "tag": "0003_recycle_bin", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1775100000000, + "tag": "0004_username_plugin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts index 527b59bd..aed216de 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { admin, organization } from 'better-auth/plugins' +import { admin, organization, username } from 'better-auth/plugins' import { count, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import * as authSchema from './db/auth-schema' @@ -49,7 +49,7 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust maxAge: 60 * 5, }, }, - plugins: [admin(), organization()], + plugins: [admin(), organization(), username()], databaseHooks: { user: { create: { diff --git a/server/db/auth-schema.test.ts b/server/db/auth-schema.test.ts new file mode 100644 index 00000000..2782ada4 --- /dev/null +++ b/server/db/auth-schema.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { user } from './auth-schema.js' + +describe('auth-schema user table', () => { + it('has a username column', () => { + expect(user.username).toBeDefined() + }) + + it('username column maps to the "username" SQL column name', () => { + expect(user.username.name).toBe('username') + }) + + it('username column is text type', () => { + expect(user.username.columnType).toBe('SQLiteText') + }) + + it('username column has a unique constraint', () => { + expect(user.username.isUnique).toBe(true) + }) + + it('username column is nullable (no notNull)', () => { + expect(user.username.notNull).toBe(false) + }) + + it('has a displayUsername column', () => { + expect(user.displayUsername).toBeDefined() + }) + + it('displayUsername column maps to the "display_username" SQL column name', () => { + expect(user.displayUsername.name).toBe('display_username') + }) + + it('displayUsername column is text type', () => { + expect(user.displayUsername.columnType).toBe('SQLiteText') + }) + + it('displayUsername column has no unique constraint', () => { + expect(user.displayUsername.isUnique).toBeFalsy() + }) + + it('displayUsername column is nullable (no notNull)', () => { + expect(user.displayUsername.notNull).toBe(false) + }) +}) diff --git a/server/db/auth-schema.ts b/server/db/auth-schema.ts index 2cdcdfa8..4e710a3f 100644 --- a/server/db/auth-schema.ts +++ b/server/db/auth-schema.ts @@ -11,6 +11,8 @@ export const user = sqliteTable('user', { banned: integer('banned', { mode: 'boolean' }).default(false), banReason: text('ban_reason'), banExpires: integer('ban_expires', { mode: 'timestamp_ms' }), + username: text('username').unique(), + displayUsername: text('display_username'), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), diff --git a/server/routes/auth-username.test.ts b/server/routes/auth-username.test.ts new file mode 100644 index 00000000..dc855fcf --- /dev/null +++ b/server/routes/auth-username.test.ts @@ -0,0 +1,97 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import * as authSchema from '../db/auth-schema.js' +import { createTestApp } from '../test/setup.js' + +describe('migration 0004_username_plugin.sql', () => { + const migrationPath = join(process.cwd(), 'migrations/0004_username_plugin.sql') + const sql = readFileSync(migrationPath, 'utf-8') + + it('adds the username column to the user table', () => { + expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`username`.*text/i) + }) + + it('adds the display_username column to the user table', () => { + expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`display_username`.*text/i) + }) + + it('creates a unique index on the username column', () => { + expect(sql).toMatch(/CREATE UNIQUE INDEX.*`user_username_unique`.*ON.*`user`.*\(`username`\)/i) + }) +}) + +describe('username plugin — sign-up with username', () => { + it('sign-up with username stores the username on the user record', async () => { + const { app, db } = createTestApp() + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Alice', + email: 'alice@example.com', + password: 'password123456', + username: 'alice42', + }), + }) + const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'alice@example.com')) + expect(users[0].username).toBe('alice42') + }) + + it('sign-up without username leaves the username column null', async () => { + const { app, db } = createTestApp() + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Bob', email: 'bob@example.com', password: 'password123456' }), + }) + const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'bob@example.com')) + expect(users[0].username).toBeNull() + }) + + it('sign-up with duplicate username returns a non-200 response', async () => { + const { app } = createTestApp() + await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'First', + email: 'first@example.com', + password: 'password123456', + username: 'shared_handle', + }), + }) + const res = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Second', + email: 'second@example.com', + password: 'password123456', + username: 'shared_handle', + }), + }) + expect(res.status).not.toBe(200) + }) + + it('two users with different usernames both register successfully', async () => { + const { app, db } = createTestApp() + const res1 = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'User1', email: 'u1@example.com', password: 'password123456', username: 'user1' }), + }) + const res2 = await app.request('/api/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'User2', email: 'u2@example.com', password: 'password123456', username: 'user2' }), + }) + expect(res1.status).toBe(200) + expect(res2.status).toBe(200) + const allUsers = await db.select().from(authSchema.user) + const usernames = allUsers.map((u) => u.username) + expect(usernames).toContain('user1') + expect(usernames).toContain('user2') + }) +}) diff --git a/server/test/setup.ts b/server/test/setup.ts index ed23b754..b93d14e5 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -17,6 +17,8 @@ const AUTH_SCHEMA_SQL = ` banned INTEGER DEFAULT 0, ban_reason TEXT, ban_expires INTEGER, + username TEXT UNIQUE, + display_username TEXT, created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) ); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 276a0467..ee19ca79 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,7 +1,9 @@ +import { usernameClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' export const authClient = createAuthClient({ baseURL: import.meta.env.VITE_API_URL || '', + plugins: [usernameClient()], }) export const { signIn, signUp, signOut, useSession } = authClient