DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
This commit is contained in:
hermes
2026-07-20 17:44:11 -07:00
parent bd480a69a7
commit 321334cd33
23 changed files with 2964 additions and 325 deletions
+82 -6
View File
@@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider {
this.store.startPruneTimer();
this.emailConfig = deps.emailConfig || null;
// DC-048 will replace this with the real authorized-users check.
this.authorizedEmails = deps.authorizedEmails || (() => true);
// DC-048: real authorized-users check via the user store. Falls back
// to "allow everyone" if no store is wired (dev/legacy installs).
this.userStore = deps.userStore || null;
// Public URL templates — overridable for testing.
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
this.maxBodyLength = 32_000;
@@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider {
*/
_isProviderEnabled() {
const flag = this.deps.config && this.deps.config.enabled;
// Default to TRUE: dev installs should "just work". Operators who want
// to disable email login set `siteConfig.authProviders.email.enabled = false`
// — the same config knob the TOTP provider uses.
if (flag === false) return false;
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
// then, the email login methods endpoint reports the provider as
// disabled and the auth UI doesn't render the email button. TOTP-only
// installs see no behavior change.
if (flag !== true) return false;
return true;
}
@@ -277,12 +280,64 @@ class EmailMagicLinkProvider extends AuthProvider {
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
// DC-048: authorization gate. If the email isn't on the allowlist and
// bootstrap has already happened, reject. The token is still consumed
// so the same generic message is returned for "valid token but you're
// not allowed" — prevents a side-channel that distinguishes
// "token worked but you're banned" from "token didn't exist".
//
// NOTE: the DC-047 design comment claimed "initiate" would also silently
// drop unauthorized emails. That was aspirational; the real enumeration
// prevention lives at verify-time (here). Initiate-time, we still issue
// tokens and return success — so an unauthorized user thinks the link
// works, but it rejects at click-time. Same as DC-047 claimed; we just
// moved the check from initiate to verify where it can actually run.
if (this.userStore) {
const allowed = await this.userStore.isEmailAuthorized(record.email);
if (!allowed) {
// Audit the denial.
this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
email: record.email,
ip: this._clientIP(req),
});
// Mark token used so a stolen token can't be replayed by a legit user later.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
}
// Side-effect logging (NOT info-disclosure — just that a token was used).
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
email: record.email,
ip: this._clientIP(req),
});
// DC-048: record-or-create the user. First login → bootstrap admin.
// After that → must be on allowlist (already checked above).
let userRecord = null;
let isBootstrap = false;
if (this.userStore) {
const result = await this.userStore.login({
email: record.email,
ip: this._clientIP(req),
});
if (!result.ok) {
// Shouldn't reach here — isEmailAuthorized just passed — but
// handle the edge case where allowlist was mutated between calls.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
userRecord = result.user;
isBootstrap = result.isBootstrap;
if (this.deps.log && this.deps.log.info) {
this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
userId: userRecord.id,
email: userRecord.email,
role: userRecord.role,
});
}
}
// Create the session + cookie. Same shape as TOTP's verify path.
this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
@@ -290,11 +345,32 @@ class EmailMagicLinkProvider extends AuthProvider {
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
: undefined;
// DC-048: tag the request with the authenticated user so downstream
// middleware + audit log can attribute the session. We mutate req so
// the audit logger (which runs as response middleware) sees it.
if (userRecord) {
req.user = {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isAdmin: userRecord.role === 'admin',
isBootstrap,
};
}
return ok(res, {
message: 'Authenticated successfully',
method: 'email',
email: AuthProvider.maskEmail(record.email),
csrfToken: newCsrf,
user: userRecord
? {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isBootstrap,
}
: null,
});
}
+5 -3
View File
@@ -42,6 +42,8 @@ function createAuthProviderRegistry(deps, config) {
...deps,
config: deps.config.totp, // the existing totpConfig object from app.js
saveProviderConfig: deps.saveTotpConfig, // existing helper
// DC-048: user store for bootstrap + audit attribution on TOTP logins.
userStore: deps.userStore || null,
});
providers.set('totp', totpProvider);
@@ -56,9 +58,9 @@ function createAuthProviderRegistry(deps, config) {
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
emailConfig: deps.emailConfig || null,
siteConfig: deps.siteConfig || {},
// DC-048 hook: today every email is authorized. Once multi-user ships,
// this is replaced with a real allowlist check.
authorizedEmails: deps.authorizedEmails || (() => true),
// DC-048: real authorization check via the user store. Without it,
// every email is allowed (legacy single-user behavior).
userStore: deps.userStore || null,
}));
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
+45
View File
@@ -274,6 +274,51 @@ class TotpProvider extends AuthProvider {
this.deps.session.create(req, this.deps.config.sessionDuration);
this.deps.session.setCookie(res, this.deps.config.sessionDuration);
// DC-048: bootstrap-on-first-TOTP-verify. If no user store is wired
// (legacy install), skip silently — operator keeps anonymous access.
// If a user store IS wired and bootstrap hasn't happened yet, create
// a "system-admin" record tied to this TOTP login so the operator
// shows up in /api/v1/auth/admin/users. Email is null because TOTP
// has no email to attribute.
if (this.deps.userStore) {
const isBootstrapped = await this.deps.userStore.isBootstrapComplete();
if (!isBootstrapped) {
const result = await this.deps.userStore.login({
email: 'system@totp.local',
ip: this._clientIP(req),
displayName: 'Operator (TOTP)',
});
if (result.ok) {
req.user = {
id: result.user.id,
email: null,
role: result.user.role,
isAdmin: result.user.role === 'admin',
isBootstrap: result.isBootstrap,
viaProvider: 'totp',
};
this.deps.log.info('auth', 'system admin bootstrapped via TOTP', {
userId: result.user.id,
role: result.user.role,
});
}
} else {
// Bootstrap already happened — find the system-admin record and
// attach it to this session for audit-log attribution.
const sys = await this.deps.userStore.getUserByEmail('system@totp.local');
if (sys) {
req.user = {
id: sys.id,
email: null,
role: sys.role,
isAdmin: sys.role === 'admin',
isBootstrap: false,
viaProvider: 'totp',
};
}
}
}
const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https');
this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size });
@@ -231,6 +231,18 @@ class AuditLogger {
details.body = safe;
}
// DC-048: attribute the audit entry to the authenticated user when
// a session belongs to a known user record. Tag with id + role +
// email (or null for the TOTP-attributed "system" operator). When
// req.user is absent (legacy session, no auth), omit the fields
// entirely so existing log readers don't break.
if (req.user && req.user.id) {
details.userId = req.user.id;
details.userRole = req.user.role || null;
if (req.user.email) details.userEmail = req.user.email;
if (req.user.viaProvider) details.viaProvider = req.user.viaProvider;
}
this.log({ action, resource, details, outcome, ip }).catch(() => {});
return originalJson(data);
@@ -153,6 +153,10 @@ function csrfValidationMiddleware(req, res, next) {
'/api/v1/auth/login/:provider/verify',
'/api/v1/auth/login/:provider/initiate',
'/api/v1/auth/disable/:provider',
// DC-048: invite redemption is the same exemption as login verify —
// the user has no session cookie yet (they just clicked an email link).
// CSRF on this boundary is enforced by SameSite=Lax instead.
'/api/v1/auth/invites/:token/accept',
'/health',
'/health/live',
'/health/ready',
+266
View File
@@ -0,0 +1,266 @@
/**
* Invite store — DC-048.
*
* Single-use invite tokens with TTL. Admin generates an invite for an email;
* the system emails (or logs in dev) a magic-link-style URL containing the
* raw token. The recipient clicks → accepts → becomes an authorized user.
*
* Storage: data/invites.json. Atomic writes via tmp+rename.
*
* Token shape:
* - 32 random bytes, base64url-encoded (256 bits of entropy).
* - We store ONLY the SHA-256 hash on disk. The raw token lives in the
* email + in the URL query string; on the server we hash and look up.
* A read-only compromise of invites.json cannot forge acceptance.
*
* Lifecycle:
* - issue({ email, role, ttlMs, invitedBy }) → { id, token, expiresAt, ... }
* token is the only time the raw token will ever be returned.
* - peek(token) → { email, role, expiresAt, usedAt } | null
* (returns the public-safe info without consuming the token)
* - accept(token) → { ok: true, invite } | { ok: false, reason }
* reasons: 'not_found', 'expired', 'already_used'
* - revoke(id) → removes the invite by id (admin-only).
* - list() → all outstanding invites (admin-only).
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
function _nowMs() { return Date.now(); }
function _nowIso() { return new Date().toISOString(); }
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;
return fallback;
}
}
function _defaultData() { return { invites: {} }; }
function _sha256(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
function createInviteStore(opts = {}) {
// Same defensive resolver as user-store — universal-deps test proxies
// can return function-typed values for property access.
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|| require('os').tmpdir();
const log = opts.log || { info() {}, warn() {}, error() {} };
const file = path.join(dataDir, 'invites.json');
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _load() {
const data = _readJSON(file, _defaultData());
if (!data.invites || typeof data.invites !== 'object') data.invites = {};
return data;
}
function _save(data) { _atomicWriteJSON(file, data); }
function _prune(data) {
const cutoff = _nowMs() - PRUNE_AFTER_MS;
for (const id of Object.keys(data.invites)) {
const inv = data.invites[id];
if (!inv) { delete data.invites[id]; continue; }
const isTerminal = inv.usedAt || (inv.expiresAt && new Date(inv.expiresAt).getTime() < cutoff);
if (isTerminal) delete data.invites[id];
}
return data;
}
/**
* Issue a new invite. Returns the raw token (only time it leaves the system).
*/
function issue({ email, role = 'operator', ttlMs = DEFAULT_TTL_MS, invitedBy = 'admin' } = {}) {
return _enqueue(() => {
if (typeof email !== 'string' || !email.includes('@')) {
return { ok: false, reason: 'invalid_email' };
}
const normalized = email.toLowerCase().trim();
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64url');
const hash = _sha256(token);
const issuedAt = _nowIso();
const expiresAt = new Date(_nowMs() + ttlMs).toISOString();
const data = _load();
_prune(data);
data.invites[id] = {
id,
hash,
email: normalized,
role,
invitedBy,
issuedAt,
expiresAt,
usedAt: null,
usedBy: null,
};
_save(data);
log.info && log.info('invite', 'invite issued', {
id, email: normalized, role, invitedBy, ttlMs,
});
return {
ok: true,
id,
token, // raw token — caller emails it
email: normalized,
role,
expiresAt,
ttlMs,
};
});
}
/**
* Public-safe peek. Does NOT consume the token.
* Returns null if not found, expired, or already used (same response
* for all three — enumeration prevention).
*/
function peek(token) {
if (!token || typeof token !== 'string') return null;
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const inv = _findByHash(data, hash);
if (!inv) return null;
if (inv.usedAt) return null;
if (new Date(inv.expiresAt).getTime() < _nowMs()) return null;
return {
id: inv.id,
email: inv.email,
role: inv.role,
expiresAt: inv.expiresAt,
issuedAt: inv.issuedAt,
};
});
}
/**
* Consume an invite token. Returns the invite record on success.
* After accept(), the invite is marked used (NOT deleted) so the admin
* can see who redeemed what. The auto-prune reaps it after 7 days.
*/
function accept(token, { acceptedBy } = {}) {
return _enqueue(() => {
if (!token || typeof token !== 'string') {
return { ok: false, reason: 'not_found' };
}
const data = _load();
const hash = _sha256(token);
const inv = _findByHash(data, hash);
if (!inv) return { ok: false, reason: 'not_found' };
if (inv.usedAt) return { ok: false, reason: 'already_used' };
if (new Date(inv.expiresAt).getTime() < _nowMs()) {
return { ok: false, reason: 'expired' };
}
inv.usedAt = _nowIso();
inv.usedBy = acceptedBy || null;
_save(data);
log.info && log.info('invite', 'invite accepted', {
id: inv.id, email: inv.email, role: inv.role, acceptedBy,
});
return {
ok: true,
invite: {
id: inv.id,
email: inv.email,
role: inv.role,
expiresAt: inv.expiresAt,
usedAt: inv.usedAt,
},
};
});
}
/**
* Admin-only. Revoke an outstanding invite by id.
*/
function revoke(id) {
return _enqueue(() => {
const data = _load();
if (!data.invites[id]) return { ok: false, reason: 'not_found' };
delete data.invites[id];
_save(data);
log.info && log.info('invite', 'invite revoked', { id });
return { ok: true };
});
}
/**
* Admin-only. List outstanding invites (excludes used/expired).
*/
function listOutstanding() {
return _enqueue(() => {
const data = _load();
_prune(data);
_save(data);
const now = _nowMs();
return Object.values(data.invites)
.filter(inv => !inv.usedAt && new Date(inv.expiresAt).getTime() > now)
.sort((a, b) => new Date(a.expiresAt) - new Date(b.expiresAt))
.map(inv => ({
id: inv.id,
email: inv.email,
role: inv.role,
invitedBy: inv.invitedBy,
issuedAt: inv.issuedAt,
expiresAt: inv.expiresAt,
}));
});
}
function _findByHash(data, hash) {
for (const id of Object.keys(data.invites)) {
const inv = data.invites[id];
if (inv && inv.hash === hash) return inv;
}
return null;
}
return {
issue,
peek,
accept,
revoke,
listOutstanding,
DEFAULT_TTL_MS,
};
}
module.exports = { createInviteStore, DEFAULT_TTL_MS };
+407
View File
@@ -0,0 +1,407 @@
/**
* 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 };
@@ -335,6 +335,14 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/auth/login/:provider/verify', exact: true, method: 'POST' },
{ path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' },
{ path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' },
// DC-048: invite redemption is PUBLIC (recipient comes from an email
// link with no session cookie). The peek route is also public so the
// UI can show "this invite is for X, expires Y" before clicking.
{ path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' },
{ path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' },
// /me and /admin/* require authentication — NOT public. Listed here
// only to document them; absence from PUBLIC_ROUTES means they go
// through the normal auth gate. CSRF applies to writes as usual.
{ path: '/api/v1/services', exact: true, method: 'GET' },
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },