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 } /** * Ensures the admin user defined by ADMIN_USERNAME / ADMIN_PASSWORD env vars * exists and has the correct password. Runs on every login attempt so that * changing the env var (and restarting the container) takes effect immediately. * Does nothing when ADMIN_PASSWORD is not set. */ export async function seedAdminIfEmpty(): Promise { const password = process.env.ADMIN_PASSWORD if (!password) return const username = process.env.ADMIN_USERNAME ?? 'admin' try { const existing = await findUserByUsername(username) if (!existing) { await createUser(username, password, 'admin') console.log(`[auth] Created admin user: "${username}"`) } else { // Always sync the password so env-var changes take effect after restart const matches = await verifyPassword(password, existing.passwordHash, existing.salt) if (!matches) { await updatePassword(existing.id, password) console.log(`[auth] Synced password for admin user: "${username}"`) } } } catch (e) { console.error('[auth] Failed to sync admin user:', e) } }