import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID } from '../middleware/auth.js'; import { hashPassword, comparePassword } from '../auth/passwords.js'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; const router = express.Router(); // Real users = anyone except the seeded dev row. async function realUserCount() { const { rows } = await pool.query( `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]); return rows[0].n; } // GET /api/v1/auth/setup-required // Cheap, no auth. Used by AuthGate to decide between Login and Setup screens. router.get('/setup-required', async (_req, res, next) => { try { res.json({ required: (await realUserCount()) === 0 }); } catch (err) { next(err); } }); const MIN_PASSWORD_LEN = 12; function badRequest(res, msg) { return res.status(400).json({ error: msg }); } // POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists. router.post('/setup', async (req, res, next) => { try { const { username, password } = req.body || {}; if (!username || typeof username !== 'string') return badRequest(res, 'username required'); if (!password || typeof password !== 'string') return badRequest(res, 'password required'); if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters'); if ((await realUserCount()) > 0) { return res.status(409).json({ error: 'setup already complete' }); } const hash = await hashPassword(password); const { rows } = await pool.query( `INSERT INTO users (username, password_hash, display_name, role) VALUES ($1, $2, $1, 'admin') RETURNING id, username, display_name`, [username.trim(), hash] ); const user = rows[0]; // Immediately log them in. req.session.user_id = user.id; req.session.first_seen_at = Date.now(); req.session.last_seen_at = Date.now(); await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); res.json({ user }); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'username already exists' }); next(err); } }); // POST /api/v1/auth/login — authenticate an existing user by username + password. router.post('/login', async (req, res, next) => { try { const { username, password } = req.body || {}; if (!username || !password) return res.status(401).json({ error: 'invalid credentials' }); const { rows } = await pool.query( `SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`, [username.trim(), DEV_USER_ID] ); if (rows.length === 0) { // Pre-computed bcrypt hash of a value that no real password input will match. // Used to keep the user-not-found response time uniform with the wrong-password // path (~180ms at cost 12) so user enumeration via timing isn't possible. await comparePassword(password, DUMMY_PASSWORD_HASH); return res.status(401).json({ error: 'invalid credentials' }); } const user = rows[0]; if (!(await comparePassword(password, user.password_hash))) { return res.status(401).json({ error: 'invalid credentials' }); } req.session.user_id = user.id; req.session.first_seen_at = Date.now(); req.session.last_seen_at = Date.now(); // The critical line — wait for the row to land in `sessions` before responding. // Without this, the SPA's next request races the store write, hits 401, and // the prior bounce-to-login logic produced an infinite loop. await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]) .catch(err => console.error('[auth] last_login_at update failed:', err.message)); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } }); export default router; export { realUserCount };