← back to Commercialrealestate

scripts/crcp-accounts.js

481 lines

// crcp-accounts.js — P1 subscription-layer for the CRCP deal-flow tool (docs/TOOL-SPEC.md).
// Per-user accounts (magic-link) + saved searches + watchlist, layered ON TOP of the existing
// whole-site basic-auth. Durable local JSON store (atomic write, same pattern as agent-contacts).
// $0, local, reversible. Mount from serve.js:  require('./crcp-accounts')(app, ROOT);
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

module.exports = function mountAccounts(app, ROOT) {
  const FILE = path.join(ROOT, 'data', 'crcp-accounts.json');
  const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return {}; } };
  const save = (db) => { const t = FILE + '.tmp'; fs.writeFileSync(t, JSON.stringify(db, null, 2)); fs.renameSync(t, FILE); };
  const blank = () => ({ users: {}, tokens: {}, sessions: {}, saved: {}, watch: {} });
  const norm = e => String(e || '').trim().toLowerCase();
  const rid = () => crypto.randomBytes(24).toString('hex');
  const now = () => Date.now();
  const cookies = req => { const o = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) o[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return o; };

  // ── password hashing (Node built-in scrypt — no plaintext at rest, no new deps) ──
  // scrypt is deliberately slow/memory-hard so a leaked store can't be brute-forced cheaply.
  const hashPw = (pw, salt) => crypto.scryptSync(String(pw), salt, 32).toString('hex');
  function verifyPw(pw, salt, expectedHex) {
    if (!salt || !expectedHex) return false;
    const got = Buffer.from(hashPw(pw, salt), 'hex');
    const exp = Buffer.from(expectedHex, 'hex');
    // timingSafeEqual throws on length mismatch — guard first so a wrong-length guess can't crash.
    return got.length === exp.length && crypto.timingSafeEqual(got, exp);
  }
  const setSession = (res, sid) => res.set('Set-Cookie', `crcp_sid=${sid}; Path=/; Max-Age=${30 * 86400}; HttpOnly; SameSite=Lax`);

  function userOf(req) {
    const db = load(); const sid = cookies(req).crcp_sid;
    const s = sid && db.sessions[sid];
    if (!s || s.exp < now()) return null;
    const u = db.users[s.email] || {};
    // s.email is the generic user KEY: a real email for magic-link users, a username for password
    // users. We surface both plus the personalization profile (role/industry/name) AND the
    // permission tier `perm` (admin|standard). `role` is the JOB type (loan_officer, investor, ...)
    // that drives the persona UI; `perm` is the ACCESS level that gates the admin console — two
    // orthogonal axes, deliberately not merged (Frank is job=loan_officer AND perm=standard).
    return { email: s.email, tier: u.tier || 'free', username: u.username || null,
             name: u.name || null, role: u.role || null, industry: u.industry || null,
             company: u.company || null,
             perm: u.perm === 'admin' ? 'admin' : 'standard' };
  }
  // Require an admin session on an admin-only route. Returns the user object, or null after
  // having already sent the 401/403 response (caller just `if (!requireAdmin(req,res)) return;`).
  function requireAdmin(req, res) {
    const u = userOf(req);
    if (!u) { res.status(401).json({ error: 'sign in' }); return null; }
    if (u.perm !== 'admin') { res.status(403).json({ error: 'admin only' }); return null; }
    return u;
  }

  // ── magic-link auth ──────────────────────────────────────────────────────────
  // POST /auth/request {email} -> create a 15-min token + link. Emails it when a mailer is wired
  // (CRCP_MAIL=1 via George), else returns the link so login works in dev. Login email to one's OWN
  // address is transactional, not a send-to-list.
  app.post('/auth/request', (req, res) => {
    const email = norm(req.body && req.body.email);
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ error: 'valid email required' });
    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
    if (!db.users[email]) db.users[email] = { tier: 'free', created: new Date().toISOString() };
    const tok = rid(); db.tokens[tok] = { email, exp: now() + 15 * 60 * 1000 }; save(db);
    const base = process.env.CRCP_BASE || `${req.protocol}://${req.get('host')}`;
    const link = `${base}/auth/verify?token=${tok}`;
    let emailed = false;
    if (process.env.CRCP_MAIL === '1') {
      try { require('child_process').execFile('node', [path.join(__dirname, 'send-magic-link.js'), email, link], () => {}); emailed = true; } catch (_) {}
    }
    res.json({ ok: true, emailed, magic_link: emailed ? undefined : link });   // link returned only in dev
  });

  app.get('/auth/verify', (req, res) => {
    const db = load(); const t = db.tokens && db.tokens[req.query.token];
    if (!t || t.exp < now()) return res.status(400).send('Link expired or invalid — request a new one.');
    delete db.tokens[req.query.token];
    const sid = rid(); db.sessions[sid] = { email: t.email, exp: now() + 30 * 86400000 }; save(db);
    setSession(res, sid);
    res.redirect('/deals-flow.html');
  });
  app.post('/auth/logout', (req, res) => {
    const db = load(); const sid = cookies(req).crcp_sid; if (sid && db.sessions[sid]) { delete db.sessions[sid]; save(db); }
    res.set('Set-Cookie', 'crcp_sid=; Path=/; Max-Age=0').json({ ok: true });
  });

  // ── username + password auth (Steve 2026-07-31) ────────────────────────────────
  // A named-user login (e.g. Frank the loan officer) that mints the SAME crcp_sid session as
  // magic-link, so saved-searches + watchlist work identically. The user key for a password
  // account is its lowercased username, stored under db.users like any other account.
  const uname = s => String(s || '').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
  // In-memory brute-force throttle (defense-in-depth — the whole site already sits behind basic
  // auth, but this caps guesses against a known username too). Keyed by ip|username; after 8 fails
  // it locks that key for 5 min. Resets on success. Process-local + reversible — no store, no dep.
  const _fails = new Map();
  const LOCK_N = 8, LOCK_MS = 5 * 60 * 1000, FAILS_CAP = 20000;
  // Dummy hash so a login for a NONEXISTENT user still pays the full scrypt cost — otherwise the
  // ~26ms hash vs ~1ms skip is a timing oracle that enumerates real usernames (contrarian #1).
  const _dummy = (() => { const salt = crypto.randomBytes(16).toString('hex'); return { salt, hash: hashPw(crypto.randomBytes(8).toString('hex'), salt) }; })();
  // Prune lapsed locks AND stale transient fail-records (n<8 entries have no `until`, so without
  // this they'd never be collected → unbounded Map from attacker-chosen usernames, contrarian #2).
  const gc = () => {
    const cutoff = now() - LOCK_MS;
    for (const [k, v] of _fails) if ((v.until && v.until < now()) || (!v.until && (v.ts || 0) < cutoff)) _fails.delete(k);
    if (_fails.size > FAILS_CAP) _fails.clear();   // hard ceiling — never let attacker input grow it without bound
  };
  app.post('/auth/login', (req, res) => {
    const b = req.body || {};
    const username = uname(b.username);
    if (!username || !b.password) return res.status(400).json({ error: 'username and password required' });
    gc();
    const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
    const fk = ip + '|' + username;
    const f = _fails.get(fk);
    if (f && f.until && f.until > now()) {
      return res.status(429).json({ error: 'too many attempts — try again in a few minutes' });
    }
    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
    const u = db.users[username];
    // Constant-ish work either way: real user → verify real hash; missing user → verify the dummy
    // (same scrypt cost), so timing can't distinguish "no such user" from "wrong password".
    const valid = (u && u.passhash) ? verifyPw(b.password, u.salt, u.passhash) : (verifyPw(b.password, _dummy.salt, _dummy.hash), false);
    if (!valid) {
      let rec = f;
      if (!rec || (rec.until && rec.until <= now())) rec = { n: 0 };   // fresh, or reset a lapsed lock
      rec.n += 1; rec.ts = now();
      if (rec.n >= LOCK_N) rec.until = now() + LOCK_MS;
      _fails.set(fk, rec);
      return res.status(401).json({ error: 'invalid username or password' });   // same message either way (no user-enumeration)
    }
    _fails.delete(fk);   // clean slate on success
    const sid = rid(); db.sessions[sid] = { email: username, exp: now() + 30 * 86400000 };
    u.last_login = new Date().toISOString(); save(db);
    setSession(res, sid);
    res.json({ ok: true, me: userOf({ headers: { cookie: `crcp_sid=${sid}` } }) });
  });

  // ── self-serve registration: any visitor can create their OWN account (their name + password) so
  //    their saved settings follow them. Standard perm only (admin is never self-granted). Auto-logs in. ──
  app.post('/auth/register', (req, res) => {
    const b = req.body || {};
    const username = uname(b.username);
    if (!username || !b.password) return res.status(400).json({ error: 'name and password required' });
    if (username.length < 3) return res.status(400).json({ error: 'name must be at least 3 characters' });
    if (String(b.password).length < 6) return res.status(400).json({ error: 'password must be at least 6 characters' });
    const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
    if (db.users[username] && db.users[username].passhash) return res.status(409).json({ error: 'that name is taken — sign in instead' });
    const salt = rid();
    const disp = String(b.name != null ? b.name : b.username).replace(/[<>]/g, '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 80) || username;
    db.users[username] = Object.assign(db.users[username] || {}, {
      tier: 'free', username, name: disp, perm: 'standard',
      salt, passhash: hashPw(b.password, salt), created: new Date().toISOString(), self_registered: true
    });
    const sid = rid(); db.sessions[sid] = { email: username, exp: now() + 30 * 86400000 };
    db.users[username].last_login = new Date().toISOString(); save(db);
    setSession(res, sid);
    res.json({ ok: true, me: userOf({ headers: { cookie: `crcp_sid=${sid}` } }) });
  });

  // Set the signed-in user's personalization profile (role + industry + display name). This is
  // what the first-run "what industry are you?" picker posts to.
  const ROLES = { loan_officer: 'Mortgage / Lending', listing_agent: 'Brokerage — Listing', buyers_agent: 'Brokerage — Buy-side', investor: 'Investment / Principal', appraiser: 'Appraisal / Valuation', other: 'Other' };
  // Sanitize user-controlled profile strings AT REST (defense-in-depth on top of render-time esc()):
  // strip angle brackets + control chars so a stored value can't become live markup on any future
  // page that forgets to escape (contrarian #c).
  const clean = s => String(s).replace(/[<>]/g, '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 80);
  app.post('/auth/profile', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const b = req.body || {};
    const db = load(); const rec = db.users[u.email]; if (!rec) return res.status(404).json({ error: 'no account' });
    if (b.role != null) { const r = String(b.role); rec.role = ROLES[r] ? r : 'other'; rec.industry = b.industry ? clean(b.industry) : ROLES[rec.role]; }
    if (b.name != null) rec.name = clean(b.name);
    rec.profiled_at = new Date().toISOString(); save(db);
    res.json({ ok: true, me: userOf(req) });
  });

  app.get('/api/me', (req, res) => res.json(userOf(req) || { email: null }));

  // ── seed accounts (idempotent — never overwrites an existing account's password/profile) ──
  // Frank the loan officer is the FIRST user: a STANDARD account (leaves per-listing notes + uses
  // the shared contact book as his CRM). An ADMIN account is seeded alongside so someone can manage
  // users from day one. Creds are env-overridable per deploy:
  //   Frank : CRCP_SEED_USER / CRCP_SEED_PASS   (default frank / arcstone1998)
  //   Admin : CRCP_ADMIN_USER / CRCP_ADMIN_PASS (default admin / DW2024!)
  (function seedAccounts() {
    try {
      const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
      let dirty = false;

      // Frank — the first user, a standard-permission loan officer.
      const fkey = uname(process.env.CRCP_SEED_USER || 'frank');
      if (!db.users[fkey]) {
        const salt = rid();
        db.users[fkey] = {
          tier: 'pro', perm: 'standard', username: fkey, name: 'Frank', role: 'loan_officer', industry: ROLES.loan_officer,
          salt, passhash: hashPw(process.env.CRCP_SEED_PASS || 'arcstone1998', salt),
          created: new Date().toISOString(), seeded: true,
        };
        dirty = true;
        console.log('[crcp-accounts] seeded standard loan-officer account: ' + fkey);
      } else if (!db.users[fkey].perm) {
        // Backfill: Frank predates the perm model — an account with no perm is a standard user.
        db.users[fkey].perm = 'standard'; dirty = true;
      }

      // Frank's firm handle (Steve 2026-08-19: "new user name Frank/Arcstone818"). Recorded as his
      // company so it surfaces on the auth line; env-overridable. Idempotent — never clobbers a value
      // Frank later edits himself.
      const fcompany = String(process.env.CRCP_SEED_COMPANY || 'Arcstone818').replace(/[<>]/g, '').slice(0, 80);
      if (db.users[fkey] && !db.users[fkey].company) { db.users[fkey].company = fcompany; dirty = true; }

      // Frank's default landing filter (Steve 2026-08-19: "Onload, show 4 unit multifamily saved
      // filter"). Seed a "4-Unit Multifamily" saved search flagged is_default so deals-flow.html
      // auto-applies it on load when no URL filter is present. Idempotent: only seeds when Frank has
      // NO saved searches at all, so a later user-created list is never disturbed. Filters map to the
      // deals-flow model — Residential use (2-4 unit income property is classed Residential) with
      // ≥4 units = the fourplex band a loan officer prospects. Reversible (delete the record).
      db.saved[fkey] = db.saved[fkey] || [];
      if (db.saved[fkey].length === 0) {
        db.saved[fkey].unshift({
          id: rid().slice(0, 12),
          name: '4-Unit Multifamily',
          filters: { use: ['Residential'], uMin: 4 },
          is_default: true,
          created: new Date().toISOString(),
          last_seen: new Date().toISOString().slice(0, 10),
          seeded: true,
        });
        dirty = true;
        console.log('[crcp-accounts] seeded default "4-Unit Multifamily" saved search for ' + fkey);
      }

      // Admin — the account-management login. Only ever auto-seeded if NO admin exists yet, so a
      // later manual demotion/reshuffle of admins is never silently re-created behind Steve's back.
      const hasAdmin = Object.values(db.users).some(u => u && u.perm === 'admin');
      if (!hasAdmin) {
        const akey = uname(process.env.CRCP_ADMIN_USER || 'admin');
        // Password policy (security review HIGH-1): honor CRCP_ADMIN_PASS if set. If it is NOT set,
        // NEVER ship the shared house default to a customer-facing prod deploy — generate a random
        // one and print it once so a forgotten env can't silently hand out `DW2024!` (which is also
        // the site basic-auth default, so it wouldn't be an independent second factor). Local/dev
        // keeps the convenient default.
        let adminPass = process.env.CRCP_ADMIN_PASS;
        if (!adminPass) {
          if (process.env.NODE_ENV === 'production') {
            adminPass = crypto.randomBytes(12).toString('base64url');
            console.log(`[crcp-accounts] PROD: CRCP_ADMIN_PASS unset — generated a random admin password for "${akey}": ${adminPass}  (set CRCP_ADMIN_PASS in the env to control it)`);
          } else {
            adminPass = 'DW2024!';
          }
        }
        if (!db.users[akey]) {
          const salt = rid();
          db.users[akey] = {
            tier: 'pro', perm: 'admin', username: akey, name: 'Admin', role: 'other', industry: ROLES.other,
            salt, passhash: hashPw(adminPass, salt),
            created: new Date().toISOString(), seeded: true,
          };
          dirty = true;
          console.log('[crcp-accounts] seeded admin account: ' + akey);
        } else {
          // A user with that key already exists but isn't admin — promote it rather than clobber.
          db.users[akey].perm = 'admin'; dirty = true;
          console.log('[crcp-accounts] promoted existing account to admin: ' + akey);
        }
      }

      // Backfill any other legacy account that has a password but no perm → standard.
      for (const [k, u] of Object.entries(db.users)) { if (u && u.passhash && !u.perm) { u.perm = 'standard'; dirty = true; } }

      if (dirty) save(db);
    } catch (e) { console.error('[crcp-accounts] seed failed:', e.message); }
  })();

  // ── breaking-news ticker: JUST LISTED (active listings) + JUST CLOSED (newest county sales) ──
  // Pure read off local JSON, cached in-memory (5-min TTL) so the 22k-row deals file isn't
  // re-parsed per request. $0, local, no outward fetch.
  let _tick = { at: 0, items: [] };
  const fmtMoney = n => (n == null || isNaN(+n)) ? null : '$' + Math.round(+n).toLocaleString();
  app.get('/api/ticker', (req, res) => {
    if (now() - _tick.at < 5 * 60 * 1000 && _tick.items.length) return res.json({ items: _tick.items });
    const items = [];
    try {
      const lj = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'listings.json'), 'utf8'));
      (lj.listings || []).filter(l => String(l.status || '').toLowerCase() === 'active').slice(0, 40).forEach(l => {
        const bits = [l.type, fmtMoney(l.price), l.units ? l.units + ' units' : null, l.cap_rate ? l.cap_rate + '% cap' : null].filter(Boolean);
        items.push({ tag: 'JUST LISTED', kind: 'listed', text: `${l.address}${l.city ? ', ' + l.city : ''} · ${bits.join(' · ')}` });
      });
    } catch (_) {}
    try {
      const dj = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'deals-flow.json'), 'utf8'));
      (dj.deals || []).filter(d => d.price && d.date).sort((a, b) => String(b.date).localeCompare(String(a.date))).slice(0, 25).forEach(d => {
        const bits = [d.use, fmtMoney(d.price), d.units ? d.units + ' units' : null, d.date].filter(Boolean);
        items.push({ tag: 'JUST CLOSED', kind: 'closed', text: `${d.address} · ${bits.join(' · ')}` });
      });
    } catch (_) {}
    // Interleave listed/closed so the tape alternates instead of clumping.
    const listed = items.filter(i => i.kind === 'listed'), closed = items.filter(i => i.kind === 'closed'), mix = [];
    for (let i = 0; i < Math.max(listed.length, closed.length); i++) { if (listed[i]) mix.push(listed[i]); if (closed[i]) mix.push(closed[i]); }
    _tick = { at: now(), items: mix.slice(0, 48) };
    res.json({ items: _tick.items });
  });

  // ── saved searches ───────────────────────────────────────────────────────────
  app.get('/api/saved-searches', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    res.json({ saved: (load().saved[u.email]) || [] });
  });
  app.post('/api/saved-searches', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const { name, filters } = req.body || {};
    if (!filters || typeof filters !== 'object') return res.status(400).json({ error: 'filters required' });
    const db = load(); db.saved[u.email] = db.saved[u.email] || [];
    const rec = { id: rid().slice(0, 12), name: String(name || 'Saved search').slice(0, 80), filters, created: new Date().toISOString(), last_seen: new Date().toISOString().slice(0, 10) };
    db.saved[u.email].unshift(rec); save(db);
    res.json({ ok: true, search: rec });
  });
  app.delete('/api/saved-searches/:id', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const db = load(); db.saved[u.email] = (db.saved[u.email] || []).filter(s => s.id !== req.params.id); save(db);
    res.json({ ok: true });
  });
  // Mark ONE saved search as the default (the one deals-flow.html auto-applies on load). Clears the
  // flag on the user's other searches so exactly one is default. Pass id='' / 'none' to clear all.
  app.post('/api/saved-searches/:id/default', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const db = load(); const list = db.saved[u.email] = db.saved[u.email] || [];
    const id = req.params.id;
    let hit = false;
    list.forEach(s => { const on = (s.id === id); s.is_default = on; if (on) hit = true; });
    save(db);
    res.json({ ok: true, default: hit ? id : null });
  });

  // ── watchlist (star a deal) ────────────────────────────────────────────────────
  app.get('/api/watchlist', (req, res) => { const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' }); res.json({ ids: (load().watch[u.email]) || [] }); });
  app.post('/api/watchlist', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const id = String((req.body || {}).id || '').slice(0, 64); if (!id) return res.status(400).json({ error: 'id required' });
    const db = load(); const set = new Set(db.watch[u.email] || []);
    set.has(id) ? set.delete(id) : set.add(id); db.watch[u.email] = [...set]; save(db);
    res.json({ ok: true, ids: db.watch[u.email] });
  });

  // ── per-user UI settings (column visibility/order, rail width, theme, sort, …) ──
  // A single JSON blob per user so a signed-in user's view follows them across browsers/machines.
  // Saved on exit by the client; "last saved" is the savedAt stamp returned here.
  app.get('/api/settings', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const s = (load().settings || {})[u.email] || null;
    res.json({ settings: s ? s.data : null, savedAt: s ? s.savedAt : null });
  });
  app.post('/api/settings', (req, res) => {
    const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
    const data = (req.body || {}).settings; if (!data || typeof data !== 'object') return res.status(400).json({ error: 'settings object required' });
    const db = load(); db.settings = db.settings || {};
    db.settings[u.email] = { data, savedAt: now() }; save(db);
    res.json({ ok: true, savedAt: db.settings[u.email].savedAt });
  });

  // ── admin console: user management (admin perm required) ───────────────────────
  // Redact secrets from every user record we hand to the client — never leak salt/passhash.
  const pubUser = (key, u) => ({
    username: u.username || key, key, name: u.name || null, perm: u.perm === 'admin' ? 'admin' : 'standard',
    tier: u.tier || 'free', role: u.role || null, industry: u.industry || null,
    created: u.created || null, last_login: u.last_login || null, seeded: !!u.seeded,
    has_password: !!u.passhash, magic_only: !u.passhash,
  });
  const countAdmins = db => Object.values(db.users).filter(u => u && u.perm === 'admin').length;
  // The :key route param IS the stored account key verbatim (a username for password accounts, a
  // real email — with '@' — for magic-link accounts). It must NOT be run through uname() (which
  // strips '@'), or email-keyed accounts become unmanageable and the self-delete guard compares
  // two mangled strings (security review HIGH-2). Only bound length + strip control chars.
  const rawKey = s => String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 120);

  // Serialized read-modify-write for the accounts store, mirroring crcp-notes' _q queue. The admin
  // mutations do check-then-write on shared state (e.g. "≤1 admin left?" then delete); without a
  // queue two concurrent deletes could both pass the last-admin guard and zero out all admins
  // (security review MEDIUM-1). A mutator may `throw { status, error }` to abort WITHOUT saving.
  let _aq = Promise.resolve();
  function withAcct(mutator) {
    const run = _aq.then(() => {
      const db = load(); ['users', 'tokens', 'sessions', 'saved', 'watch'].forEach(k => db[k] = db[k] || blank()[k]);
      const out = mutator(db);   // throws to abort (no save); returns { status, body } on success
      save(db);
      return out;
    });
    _aq = run.then(() => {}, () => {});   // swallow so a rejected mutation never wedges the queue
    return run;
  }
  const sendAcct = (res) => (r) => res.status(r.status || 200).json(r.body);
  const acctErr = (res) => (e) => (e && e.status) ? res.status(e.status).json({ error: e.error })
    : res.status(500).json({ error: String(e && e.message || e) });
  // Same-origin CSRF guard for state-changing routes (defense-in-depth beyond SameSite=Lax, since
  // sibling *.agentabrams.com pages are same-site for the cookie). A same-origin fetch always sends
  // a matching Origin on POST/DELETE; we only reject a PRESENT, mismatched Origin (security MEDIUM-2).
  function csrfOk(req) {
    const o = req.headers.origin; if (!o) return true;
    try { return new URL(o).host === req.headers.host; } catch { return false; }
  }
  const denyCsrf = (req, res) => { if (csrfOk(req)) return false; res.status(403).json({ error: 'bad origin' }); return true; };

  // List every account (admin only).
  app.get('/api/admin/users', (req, res) => {
    if (!requireAdmin(req, res)) return;
    const db = load();
    const users = Object.entries(db.users).map(([k, u]) => pubUser(k, u))
      .sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')));
    res.json({ users, count: users.length, admins: countAdmins(db) });
  });

  // Create a new account (admin only). Defaults to a standard user; perm can be set to admin.
  app.post('/api/admin/users', (req, res) => {
    const me = requireAdmin(req, res); if (!me) return; if (denyCsrf(req, res)) return;
    const b = req.body || {};
    const key = uname(b.username);   // a NEW password account is keyed by its sanitized username
    if (!key) return res.status(400).json({ error: 'username required (letters, digits, . _ -)' });
    if (!b.password || String(b.password).length < 6) return res.status(400).json({ error: 'password required (min 6 chars)' });
    const perm = b.perm === 'admin' ? 'admin' : 'standard';
    const role = ROLES[b.role] ? b.role : 'loan_officer';
    withAcct(db => {
      if (db.users[key]) throw { status: 409, error: 'username already exists' };   // atomic dup-check
      const salt = rid();
      db.users[key] = {
        tier: b.tier === 'pro' ? 'pro' : (perm === 'admin' ? 'pro' : 'free'),
        perm, username: key, name: b.name ? clean(b.name) : key, role, industry: ROLES[role],
        salt, passhash: hashPw(String(b.password), salt),
        created: new Date().toISOString(), created_by: me.email,
      };
      return { status: 200, body: { ok: true, user: pubUser(key, db.users[key]) } };
    }).then(sendAcct(res)).catch(acctErr(res));
  });

  // Reset a user's password (admin only).
  app.post('/api/admin/users/:key/password', (req, res) => {
    if (!requireAdmin(req, res)) return; if (denyCsrf(req, res)) return;
    const key = rawKey(req.params.key);
    const pw = String((req.body || {}).password || '');
    if (pw.length < 6) return res.status(400).json({ error: 'password required (min 6 chars)' });
    withAcct(db => {
      const u = db.users[key]; if (!u) throw { status: 404, error: 'no such user' };
      const salt = rid(); u.salt = salt; u.passhash = hashPw(pw, salt); u.pw_reset_at = new Date().toISOString();
      return { status: 200, body: { ok: true, user: pubUser(key, u) } };
    }).then(sendAcct(res)).catch(acctErr(res));
  });

  // Change a user's permission level (admin only). Guarded so the LAST admin can never be demoted
  // (which would lock everyone out of the console) — checked atomically inside withAcct.
  app.post('/api/admin/users/:key/perm', (req, res) => {
    const me = requireAdmin(req, res); if (!me) return; if (denyCsrf(req, res)) return;
    const key = rawKey(req.params.key);
    const perm = (req.body || {}).perm === 'admin' ? 'admin' : 'standard';
    withAcct(db => {
      const u = db.users[key]; if (!u) throw { status: 404, error: 'no such user' };
      if (u.perm === 'admin' && perm === 'standard' && countAdmins(db) <= 1) throw { status: 409, error: 'cannot demote the last admin' };
      u.perm = perm;
      return { status: 200, body: { ok: true, user: pubUser(key, u) } };
    }).then(sendAcct(res)).catch(acctErr(res));
  });

  // Delete an account (admin only). Can't delete yourself or the last admin — checked atomically.
  app.delete('/api/admin/users/:key', (req, res) => {
    const me = requireAdmin(req, res); if (!me) return; if (denyCsrf(req, res)) return;
    const key = rawKey(req.params.key);
    withAcct(db => {
      const u = db.users[key]; if (!u) throw { status: 404, error: 'no such user' };
      if (key === me.email) throw { status: 409, error: 'cannot delete your own account' };   // literal-key compare
      if (u.perm === 'admin' && countAdmins(db) <= 1) throw { status: 409, error: 'cannot delete the last admin' };
      delete db.users[key]; delete db.saved[key]; delete db.watch[key]; if (db.settings) delete db.settings[key];
      // Revoke any live sessions for the deleted user so a signed-in tab loses access immediately.
      for (const [sid, s] of Object.entries(db.sessions)) if (s && s.email === key) delete db.sessions[sid];
      return { status: 200, body: { ok: true, deleted: key } };
    }).then(sendAcct(res)).catch(acctErr(res));
  });

  module.exports.userOf = userOf;   // expose for the alert job
  module.exports.requireAdmin = requireAdmin;
  console.log('[crcp-accounts] P1 subscription layer mounted (accounts + roles + admin console + saved-searches + watchlist)');
};