diff --git a/src/lib/auth/admin-config.test.ts b/src/lib/auth/admin-config.test.ts new file mode 100644 index 0000000..2be46f9 --- /dev/null +++ b/src/lib/auth/admin-config.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { configuredAdminEmails, isConfiguredAdminEmail } from './admin-config'; + +describe('admin configuration', () => { + it('normalizes and deduplicates configured admin emails', () => { + expect(configuredAdminEmails(' Admin@Example.com,owner@example.com,admin@example.com ')).toEqual([ + 'admin@example.com', + 'owner@example.com', + ]); + }); + + it('matches admin emails case-insensitively', () => { + expect(isConfiguredAdminEmail('ADMIN@example.com', 'admin@example.com')).toBe(true); + expect(isConfiguredAdminEmail('member@example.com', 'admin@example.com')).toBe(false); + expect(isConfiguredAdminEmail(null, 'admin@example.com')).toBe(false); + }); +}); diff --git a/src/lib/auth/admin-config.ts b/src/lib/auth/admin-config.ts new file mode 100644 index 0000000..d42751d --- /dev/null +++ b/src/lib/auth/admin-config.ts @@ -0,0 +1,16 @@ +export function configuredAdminEmails(value = process.env.ADMIN_EMAILS): string[] { + return [...new Set( + (value || '') + .split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean) + )]; +} + +export function isConfiguredAdminEmail( + email: string | null | undefined, + value = process.env.ADMIN_EMAILS +): boolean { + if (!email) return false; + return configuredAdminEmails(value).includes(email.toLowerCase()); +} diff --git a/src/lib/auth/admin.ts b/src/lib/auth/admin.ts index 3063614..9bb34ea 100644 --- a/src/lib/auth/admin.ts +++ b/src/lib/auth/admin.ts @@ -1,22 +1,12 @@ import { requireAuth } from '@/lib/auth'; import { users } from '@/db'; +import { isConfiguredAdminEmail } from '@/lib/auth/admin-config'; type User = typeof users.$inferSelect; -const normalizeList = (value?: string | null) => - (value || '') - .split(',') - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); - -const adminEmails = normalizeList(process.env.ADMIN_EMAILS); - export const isAdminUser = (user: User | null | undefined) => { if (!user) return false; - if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) { - return true; - } - return false; + return isConfiguredAdminEmail(user.email); }; export async function requireAdmin(): Promise { diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index d90b37e..460bb31 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -2,8 +2,8 @@ * Authentication Utilities */ -import { db, users, sessions } from '@/db'; -import { eq, inArray } from 'drizzle-orm'; +import { db, follows, users, sessions } from '@/db'; +import { eq, inArray, sql } from 'drizzle-orm'; import bcrypt from 'bcryptjs'; import { v4 as uuid } from 'uuid'; import { generateKeyPair } from '@/lib/crypto/keys'; @@ -12,6 +12,7 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k import { cookies } from 'next/headers'; import { upsertHandleEntries } from '@/lib/federation/handles'; import { registrationDisplayName } from '@/lib/auth/display-name'; +import { configuredAdminEmails } from '@/lib/auth/admin-config'; const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session'; const SESSION_COOKIE_NAME = 'synapsis_sessions'; @@ -310,15 +311,38 @@ export async function registerUser( const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; - const [user] = await db.insert(users).values({ - did, - handle: handle.toLowerCase(), - email: email.toLowerCase(), - passwordHash, - displayName: registrationDisplayName(handle, email, displayName), - publicKey, - privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), - }).returning(); + const adminEmails = configuredAdminEmails(); + const adminUsers = adminEmails.length > 0 + ? (await db.query.users.findMany({ + where: { email: { in: adminEmails } }, + })).filter((admin) => !admin.isSuspended) + : []; + + const user = await db.transaction(async (tx) => { + const [createdUser] = await tx.insert(users).values({ + did, + handle: handle.toLowerCase(), + email: email.toLowerCase(), + passwordHash, + displayName: registrationDisplayName(handle, email, displayName), + publicKey, + privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), + followingCount: adminUsers.length, + }).returning(); + + if (adminUsers.length > 0) { + await tx.insert(follows).values(adminUsers.map((admin) => ({ + followerId: createdUser.id, + followingId: admin.id, + }))); + + await tx.update(users) + .set({ followersCount: sql`${users.followersCount} + 1` }) + .where(inArray(users.id, adminUsers.map((admin) => admin.id))); + } + + return createdUser; + }); await upsertHandleEntries([{ handle: user.handle, diff --git a/src/lib/auth/registration.test.ts b/src/lib/auth/registration.test.ts new file mode 100644 index 0000000..45b11fc --- /dev/null +++ b/src/lib/auth/registration.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const users = { id: 'users.id', followersCount: 'users.followersCount' }; + const follows = Symbol('follows'); + const returning = vi.fn(); + const insertValues = vi.fn(() => ({ returning })); + const insert = vi.fn(() => ({ values: insertValues })); + const where = vi.fn(); + const set = vi.fn(() => ({ where })); + const update = vi.fn(() => ({ set })); + const tx = { insert, update }; + + return { + users, + follows, + returning, + insertValues, + insert, + where, + set, + update, + tx, + findFirst: vi.fn(), + findMany: vi.fn(), + transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)), + upsertHandleEntries: vi.fn(), + }; +}); + +vi.mock('@/db', () => ({ + db: { + query: { + users: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + }, + transaction: mocks.transaction, + }, + users: mocks.users, + follows: mocks.follows, + sessions: {}, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), + inArray: vi.fn(() => 'admin ids'), + sql: vi.fn(() => 'increment followers'), +})); + +vi.mock('bcryptjs', () => ({ + default: { + hash: vi.fn(async () => 'password hash'), + compare: vi.fn(), + }, +})); + +vi.mock('@/lib/crypto/keys', () => ({ + generateKeyPair: vi.fn(async () => ({ publicKey: 'public key', privateKey: 'private key' })), +})); + +vi.mock('@/lib/crypto/did-key', () => ({ + didKeyMatchesPublicKey: vi.fn(), + generateDID: vi.fn(() => 'did:key:new-user'), +})); + +vi.mock('@/lib/crypto/private-key', () => ({ + encryptPrivateKey: vi.fn(() => ({ encrypted: 'encrypted', salt: 'salt', iv: 'iv' })), + serializeEncryptedKey: vi.fn(() => 'serialized private key'), + isEncryptedPrivateKeyStored: vi.fn(), +})); + +vi.mock('@/lib/federation/handles', () => ({ + upsertHandleEntries: mocks.upsertHandleEntries, +})); + +import { registerUser } from './index'; + +describe('registerUser', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('ADMIN_EMAILS', 'admin@example.com, second-admin@example.com'); + vi.stubEnv('NEXT_PUBLIC_NODE_DOMAIN', 'example.social'); + mocks.findFirst.mockResolvedValue(undefined); + }); + + it('atomically follows every existing server admin for a new account', async () => { + const admins = [ + { id: 'admin-1', email: 'admin@example.com', isSuspended: false }, + { id: 'admin-2', email: 'second-admin@example.com', isSuspended: false }, + ]; + const createdUser = { id: 'user-1', handle: 'newuser', did: 'did:key:new-user' }; + mocks.findMany.mockResolvedValue(admins); + mocks.returning.mockResolvedValue([createdUser]); + + await registerUser('NewUser', 'new@example.com', 'password123'); + + expect(mocks.transaction).toHaveBeenCalledOnce(); + expect(mocks.insertValues).toHaveBeenNthCalledWith(1, expect.objectContaining({ + handle: 'newuser', + followingCount: 2, + })); + expect(mocks.insertValues).toHaveBeenNthCalledWith(2, [ + { followerId: 'user-1', followingId: 'admin-1' }, + { followerId: 'user-1', followingId: 'admin-2' }, + ]); + expect(mocks.set).toHaveBeenCalledWith({ followersCount: 'increment followers' }); + expect(mocks.upsertHandleEntries).toHaveBeenCalledOnce(); + }); + + it('does not follow suspended admins', async () => { + mocks.findMany.mockResolvedValue([ + { id: 'admin-1', email: 'admin@example.com', isSuspended: true }, + ]); + mocks.returning.mockResolvedValue([{ id: 'user-1', handle: 'newuser', did: 'did:key:new-user' }]); + + await registerUser('newuser', 'new@example.com', 'password123'); + + expect(mocks.insertValues).toHaveBeenCalledOnce(); + expect(mocks.insertValues).toHaveBeenCalledWith(expect.objectContaining({ followingCount: 0 })); + expect(mocks.update).not.toHaveBeenCalled(); + }); +});