import crypto from 'node:crypto'; import { nanoid } from 'nanoid'; import { USERS_FILE, SEED_ADMIN_USER, SEED_ADMIN_PASSWORD, SEED_ADMIN_EMAIL } from './config.js'; import { readJson, writeJson } from './store.js'; import { logEvent } from './logger.js'; let users = []; // { id, username, email, role, salt, hash, createdAt } const authSessions = new Map(); // token -> { userId, expires } const WEEK = 7 * 24 * 60 * 60 * 1000; function hashPassword(password, salt) { return crypto.scryptSync(String(password), salt, 64).toString('hex'); } function buildUser(username, email, password, role) { const salt = crypto.randomBytes(16).toString('hex'); return { id: nanoid(10), username: username.trim(), email: (email || '').trim(), role: role === 'admin' ? 'admin' : 'user', salt, hash: hashPassword(password, salt), createdAt: Date.now() }; } export async function initUsers() { const saved = await readJson(USERS_FILE, null); users = Array.isArray(saved) ? saved : []; // No auto-seed: on first run the operator creates the admin in the browser. // For unattended deploys, ADMIN_USERNAME + ADMIN_PASSWORD may pre-seed one. if (users.length === 0 && process.env.ADMIN_PASSWORD) { users = [buildUser(SEED_ADMIN_USER, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, 'admin')]; await persist(); await logEvent({ action: 'seed', message: `Pre-seeded admin "${SEED_ADMIN_USER}" from environment` }); } } export function hasUsers() { return users.length > 0; } // Create the very first admin account (first-run setup). Refuses once any user exists. export async function createFirstAdmin({ username, email, password }) { if (users.length > 0) throw new Error('Setup has already been completed.'); if (!username || !password) throw new Error('Username and password are required.'); const u = buildUser(username, email, password, 'admin'); users.push(u); await persist(); return publicUser(u); } async function persist() { await writeJson(USERS_FILE, users); } export function publicUser(u) { return u ? { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt } : null; } export function listUsers() { return users.map(publicUser); } export function getUserById(id) { return users.find((u) => u.id === id); } export function getUserByName(name) { const n = String(name || '').toLowerCase(); return users.find((u) => u.username.toLowerCase() === n); } export function adminCount() { return users.filter((u) => u.role === 'admin').length; } export async function createUser({ username, email, password, role }) { if (!username || !password) throw new Error('Username and password are required.'); if (getUserByName(username)) throw new Error('That username is already taken.'); const u = buildUser(username, email, password, role); users.push(u); await persist(); return publicUser(u); } export async function deleteUser(id, actingUserId) { const u = getUserById(id); if (!u) throw new Error('User not found.'); if (id === actingUserId) throw new Error('You cannot delete your own account.'); if (u.role === 'admin' && adminCount() <= 1) throw new Error('Cannot delete the last remaining admin.'); users = users.filter((x) => x.id !== id); await persist(); for (const [token, s] of authSessions) if (s.userId === id) authSessions.delete(token); return true; } export async function setPassword(id, password) { const u = getUserById(id); if (!u) throw new Error('User not found.'); if (!password) throw new Error('Password cannot be empty.'); u.salt = crypto.randomBytes(16).toString('hex'); u.hash = hashPassword(password, u.salt); await persist(); } export async function setRole(id, role, actingUserId) { const u = getUserById(id); if (!u) throw new Error('User not found.'); const next = role === 'admin' ? 'admin' : 'user'; if (u.role === 'admin' && next === 'user' && adminCount() <= 1) { throw new Error('Cannot demote the last remaining admin.'); } if (id === actingUserId && next === 'user') { throw new Error('You cannot remove your own admin rights.'); } u.role = next; await persist(); } export function verifyCredentials(username, password) { const u = getUserByName(username); if (!u) return null; const attempt = Buffer.from(hashPassword(password, u.salt)); const known = Buffer.from(u.hash); if (attempt.length !== known.length || !crypto.timingSafeEqual(attempt, known)) return null; return u; } // --- auth sessions (cookie tokens, in-memory) ------------------------------ export function createAuthSession(userId) { const token = nanoid(32); authSessions.set(token, { userId, expires: Date.now() + WEEK }); return token; } export function getAuthUser(token) { const s = token && authSessions.get(token); if (!s) return null; if (s.expires < Date.now()) { authSessions.delete(token); return null; } return getUserById(s.userId) || null; } export function destroyAuthSession(token) { if (token) authSessions.delete(token); }