import crypto from 'crypto' import { hashPassword, verifyPassword } from './auth' import { getDb, ensureReady } from './db' export type Role = 'admin' | 'user' export interface User { id: string username: string passwordHash: string salt: string role: Role createdAt: string } /** User fields safe to expose to the client (no credentials). */ export type PublicUser = Omit type DbRow = Record function rowToUser(row: DbRow): User { return { id: row.id as string, username: row.username as string, passwordHash: row.password_hash as string, salt: row.salt as string, role: row.role as Role, createdAt: row.created_at as string, } } function toPublic(user: User): PublicUser { const { passwordHash: _, salt: __, ...pub } = user return pub } export async function listUsers(): Promise { await ensureReady() const { rows } = await getDb().execute( 'SELECT * FROM users ORDER BY created_at ASC', ) return rows.map((r) => toPublic(rowToUser(r))) } export async function findUserByUsername(username: string): Promise { await ensureReady() const { rows } = await getDb().execute({ sql: 'SELECT * FROM users WHERE username = ? COLLATE NOCASE', args: [username], }) return rows[0] ? rowToUser(rows[0]) : undefined } export async function findUserById(id: string): Promise { await ensureReady() const { rows } = await getDb().execute({ sql: 'SELECT * FROM users WHERE id = ?', args: [id], }) return rows[0] ? rowToUser(rows[0]) : undefined } export async function createUser( username: string, password: string, role: Role, ): Promise { await ensureReady() if (!username.trim()) throw new Error('Username cannot be empty') if (password.length < 8) throw new Error('Password must be at least 8 characters') const { hash, salt } = await hashPassword(password) const user: User = { id: crypto.randomUUID(), username: username.trim(), passwordHash: hash, salt, role, createdAt: new Date().toISOString(), } try { await getDb().execute({ sql: `INSERT INTO users (id,username,password_hash,salt,role,created_at) VALUES (?,?,?,?,?,?)`, args: [user.id, user.username, user.passwordHash, user.salt, user.role, user.createdAt], }) } catch (e: unknown) { if (String(e).includes('UNIQUE')) { throw new Error(`Username "${username}" is already taken`) } throw e } return toPublic(user) } export async function deleteUser(id: string): Promise { await ensureReady() const db = getDb() const { rows: userRows } = await db.execute({ sql: 'SELECT * FROM users WHERE id = ?', args: [id], }) if (!userRows[0]) throw new Error('User not found') const user = rowToUser(userRows[0]) if (user.role === 'admin') { const { rows: adminRows } = await db.execute( "SELECT COUNT(*) as cnt FROM users WHERE role = 'admin'", ) if ((adminRows[0].cnt as number) <= 1) { throw new Error('Cannot delete the last admin account') } } await db.execute({ sql: 'DELETE FROM users WHERE id = ?', args: [id] }) } export async function updatePassword(id: string, newPassword: string): Promise { await ensureReady() if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') const { rows } = await getDb().execute({ sql: 'SELECT id FROM users WHERE id = ?', args: [id], }) if (!rows[0]) throw new Error('User not found') const { hash, salt } = await hashPassword(newPassword) await getDb().execute({ sql: 'UPDATE users SET password_hash = ?, salt = ? WHERE id = ?', args: [hash, salt, id], }) } export async function authenticateUser(username: string, password: string): Promise { const user = await findUserByUsername(username) if (!user) { await hashPassword(password) // constant-time dummy to prevent enumeration return null } const valid = await verifyPassword(password, user.passwordHash, user.salt) return valid ? user : null } export async function countUsers(): Promise { await ensureReady() const { rows } = await getDb().execute('SELECT COUNT(*) as cnt FROM users') return rows[0].cnt as number } /** * Seeds an initial admin user if no users exist yet. * Reads ADMIN_USERNAME (default "admin") and ADMIN_PASSWORD from environment. */ export async function seedAdminIfEmpty(): Promise { const count = await countUsers() if (count > 0) return const password = process.env.ADMIN_PASSWORD if (!password) return const username = process.env.ADMIN_USERNAME ?? 'admin' try { await createUser(username, password, 'admin') console.log(`[auth] Seeded initial admin user: "${username}"`) } catch (e) { console.error('[auth] Failed to seed admin user:', e) } }