← 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)');
};