/** * User store — DC-048. * * Tracks who is allowed to log in to a DashCaddy instance, and what role each * authenticated user has. Replaces the "one implicit operator" model that * DC-046/047 shipped with. * * TWO files (both live under platformPaths.dataDir): * * data/users.json — every user that has ever authenticated. * Shape: { * users: { * [userId]: { * id, email, displayName, role, * createdBy, createdAt, * lastLoginAt, lastLoginIp, loginCount * } * }, * order: [userId, ...] * } * * data/authorized-users.json — the ALLOWLIST. Emails on this list may log in. * The bootstrap user (first-ever login) is * implicitly authorized even if the file is * empty. Shape: { emails: ["a@x.com", ...] } * * Bootstrap rule: the FIRST email to ever successfully authenticate is * automatically granted role "admin" AND implicitly added to the allowlist. * This is recorded by writing a sentinel file `data/.bootstrapped` with the * admin email so we never bootstrap twice (e.g. after a restore from backup). * * Atomic writes: every persistence op writes to a .tmp file then renames. * process restart loses nothing in flight because rename is atomic on POSIX. * * Concurrency: a single in-process mutex serializes mutating ops. We don't * need cross-process locks because this API is single-instance by design. */ 'use strict'; const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); const ROLES = Object.freeze({ ADMIN: 'admin', OPERATOR: 'operator', VIEWER: 'viewer', }); // All roles recognized by the system. Used for validation only. const VALID_ROLES = new Set(Object.values(ROLES)); // Email shape — same pragmatic regex as the email provider. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function _nowIso() { return new Date().toISOString(); } function _isEmail(s) { return typeof s === 'string' && EMAIL_RE.test(s); } function _defaultUsers() { return { users: {}, order: [] }; } function _defaultAllowlist() { return { emails: [] }; } // Coerce a candidate to a writable string dataDir; return null otherwise. // Used by the factory's resolver to ignore test proxies / function-typed // values from universal-deps that the `||` short-circuit can't filter. function _resolveDataDir(opts) { const candidates = [ opts.dataDir, opts.platformPaths && opts.platformPaths.dataDir, platformPaths && platformPaths.dataDir, ]; for (const c of candidates) { if (typeof c === 'string' && c.length > 0) return c; } return require('os').tmpdir(); } function _atomicWriteJSON(filePath, data) { const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); fs.renameSync(tmp, filePath); } function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err && err.code === 'ENOENT') return fallback; // Corrupt file: log and return fallback so the API keeps serving. // The next mutation will rewrite the file cleanly. return fallback; } } /** * Factory. One user-store per process. * * @param {Object} [opts] * @param {string} [opts.dataDir] — override for tests * @param {Object} [opts.log] — structured logger */ function createUserStore(opts = {}) { // Resolve dataDir defensively — universal-deps test proxies can return // function-typed values for property access, which `||` won't filter. const dataDir = _resolveDataDir(opts); const log = opts.log || { info() {}, warn() {}, error() {} }; const usersFile = path.join(dataDir, 'users.json'); const allowlistFile = path.join(dataDir, 'authorized-users.json'); const bootstrapSentinel = path.join(dataDir, '.bootstrapped'); let _mutex = Promise.resolve(); function _enqueue(fn) { const next = _mutex.then(fn, fn); // Swallow errors on the chain so one failure doesn't poison subsequent ops. _mutex = next.catch(() => {}); return next; } function _loadUsers() { const data = _readJSON(usersFile, _defaultUsers()); if (!data.users || typeof data.users !== 'object') data.users = {}; if (!Array.isArray(data.order)) data.order = Object.keys(data.users); return data; } function _loadAllowlist() { const data = _readJSON(allowlistFile, _defaultAllowlist()); if (!Array.isArray(data.emails)) data.emails = []; return data; } function _saveUsers(data) { _atomicWriteJSON(usersFile, data); } function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); } function _bootstrapDone() { try { return fs.existsSync(bootstrapSentinel); } catch { return false; } } function _writeBootstrapSentinel(adminEmail) { _atomicWriteJSON(bootstrapSentinel, { bootstrappedAt: _nowIso(), adminEmail: adminEmail.toLowerCase(), }); } // ── Public API ───────────────────────────────────────────────────────── /** * Authenticate-or-create a user from an email. Implements the DC-048 * bootstrap rule and the authorized-users allowlist. * * Returns one of: * { ok: true, user, role, isBootstrap } * { ok: false, reason: 'not_authorized' } * * Reasons: * 'not_authorized' — email not in allowlist AND bootstrap already happened. * * If bootstrap hasn't happened yet (no users file, no .bootstrapped sentinel), * the first email that successfully passes shape validation becomes admin * AND gets added to the allowlist atomically. */ function login({ email, ip, displayName, createdBy } = {}) { return _enqueue(() => { if (!_isEmail(email)) { return { ok: false, reason: 'invalid_email' }; } const normalized = email.toLowerCase().trim(); const users = _loadUsers(); const allowlist = _loadAllowlist(); // Existing user → just bump login counters. const existing = _findUserByEmail(users, normalized); if (existing) { existing.lastLoginAt = _nowIso(); existing.lastLoginIp = ip || ''; existing.loginCount = (existing.loginCount || 0) + 1; _saveUsers(users); log.info && log.info('user', 'login existing user', { userId: existing.id, email: normalized, role: existing.role, }); return { ok: true, user: existing, role: existing.role, isBootstrap: false }; } // New email. Allow if (a) bootstrap hasn't happened, or (b) allowlisted. const bootstrapPending = !_bootstrapDone() && users.order.length === 0; const onAllowlist = allowlist.emails.includes(normalized); if (!bootstrapPending && !onAllowlist) { log.info && log.info('user', 'login denied — not on allowlist', { email: normalized }); return { ok: false, reason: 'not_authorized' }; } // Bootstrap path: first-ever user becomes admin. const isBootstrap = bootstrapPending; const role = isBootstrap ? ROLES.ADMIN : ROLES.OPERATOR; const newUser = { id: crypto.randomUUID(), email: normalized, displayName: displayName || normalized.split('@')[0], role, createdBy: createdBy || (isBootstrap ? 'bootstrap' : 'invite'), createdAt: _nowIso(), lastLoginAt: _nowIso(), lastLoginIp: ip || '', loginCount: 1, }; users.users[newUser.id] = newUser; users.order.unshift(newUser.id); // If bootstrap: implicitly allowlist + write sentinel. if (isBootstrap) { if (!allowlist.emails.includes(normalized)) { allowlist.emails.push(normalized); } _saveAllowlist(allowlist); _writeBootstrapSentinel(normalized); } _saveUsers(users); log.info && log.info('user', isBootstrap ? 'bootstrap admin created' : 'invited user created', { userId: newUser.id, email: normalized, role, }); return { ok: true, user: newUser, role, isBootstrap }; }); } /** * Add an email to the allowlist WITHOUT creating a user record. Used when * admin pre-authorizes someone who hasn't logged in yet. * * Returns { ok: true, alreadyExisted: boolean }. */ function addToAllowlist(email) { return _enqueue(() => { if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' }; const normalized = email.toLowerCase().trim(); const allowlist = _loadAllowlist(); if (allowlist.emails.includes(normalized)) { return { ok: true, alreadyExisted: true }; } allowlist.emails.push(normalized); _saveAllowlist(allowlist); log.info && log.info('user', 'added to allowlist', { email: normalized }); return { ok: true, alreadyExisted: false }; }); } /** * Remove an email from the allowlist. Does NOT delete the user record * (so the admin can read the login history) — but future logins by that * email will be rejected unless bootstrap re-runs (which it won't). */ function removeFromAllowlist(email) { return _enqueue(() => { if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' }; const normalized = email.toLowerCase().trim(); const allowlist = _loadAllowlist(); const idx = allowlist.emails.indexOf(normalized); if (idx === -1) return { ok: true, alreadyRemoved: true }; allowlist.emails.splice(idx, 1); _saveAllowlist(allowlist); log.info && log.info('user', 'removed from allowlist', { email: normalized }); return { ok: true, alreadyRemoved: false }; }); } /** * Update an existing user's role. Role must be in VALID_ROLES. * Returns { ok: true } or { ok: false, reason }. */ function setRole(userId, role) { return _enqueue(() => { if (!VALID_ROLES.has(role)) return { ok: false, reason: 'invalid_role' }; const users = _loadUsers(); const u = users.users[userId]; if (!u) return { ok: false, reason: 'not_found' }; u.role = role; _saveUsers(users); log.info && log.info('user', 'role updated', { userId, role }); return { ok: true }; }); } /** * Delete a user record AND remove from allowlist. Cannot delete the last * admin (you'd lock yourself out). Returns { ok: true } or { ok: false, reason }. */ function deleteUser(userId) { return _enqueue(() => { const users = _loadUsers(); const u = users.users[userId]; if (!u) return { ok: false, reason: 'not_found' }; // Count remaining admins. const remainingAdmins = users.order .map(id => users.users[id]) .filter(x => x && x.role === ROLES.ADMIN && x.id !== userId).length; if (u.role === ROLES.ADMIN && remainingAdmins === 0) { return { ok: false, reason: 'last_admin' }; } delete users.users[userId]; users.order = users.order.filter(id => id !== userId); // Also remove from allowlist so re-invite is a clean slate. const allowlist = _loadAllowlist(); const idx = allowlist.emails.indexOf(u.email); if (idx !== -1) { allowlist.emails.splice(idx, 1); _saveAllowlist(allowlist); } _saveUsers(users); log.info && log.info('user', 'user deleted', { userId, email: u.email }); return { ok: true }; }); } function listUsers() { return _enqueue(() => { const users = _loadUsers(); return users.order .map(id => users.users[id]) .filter(Boolean); }); } function listAllowlist() { return _enqueue(() => { const allowlist = _loadAllowlist(); return [...allowlist.emails]; }); } function getUser(userId) { return _enqueue(() => { const users = _loadUsers(); return users.users[userId] || null; }); } function getUserByEmail(email) { return _enqueue(() => { if (!_isEmail(email)) return null; const users = _loadUsers(); return _findUserByEmail(users, email.toLowerCase().trim()) || null; }); } function isBootstrapComplete() { return _enqueue(() => _bootstrapDone()); } /** * Helper for the auth system: given an email, return whether the user * is allowed to attempt login (allowlist OR bootstrap-pending). Used by * the email provider's `authorizedEmails()` dependency. */ function isEmailAuthorized(email) { return _enqueue(() => { if (!_isEmail(email)) return false; const normalized = email.toLowerCase().trim(); const allowlist = _loadAllowlist(); if (allowlist.emails.includes(normalized)) return true; const users = _loadUsers(); // Bootstrap path: if no users yet, the first login is implicitly allowed. return users.order.length === 0 && !_bootstrapDone(); }); } function _findUserByEmail(users, normalizedEmail) { for (const id of users.order) { const u = users.users[id]; if (u && u.email === normalizedEmail) return u; } return null; } return { login, addToAllowlist, removeFromAllowlist, setRole, deleteUser, listUsers, listAllowlist, getUser, getUserByEmail, isBootstrapComplete, isEmailAuthorized, // Constants for callers ROLES, VALID_ROLES, }; } module.exports = { createUserStore, ROLES, VALID_ROLES };