← back to Sublease Agentabrams
Individual client accounts (file-based, snapshot-safe): scrypt signup/login + stateless signed-cookie sessions; claim-to-account (claims link to user); /account page (signup/login/my-claims); Account nav link; broker page reflects sign-in. Behind the basic-auth wall (public flip = Steve-gated)
83016462369bb53d9b4ffd6ffbf4a4c3303165a9 · 2026-07-20 15:53:12 -0700 · Steve
Files touched
M .deploy.confM .gitignoreA lib/accounts.jsA public/account.htmlM public/broker-profile.htmlM public/index.htmlM server.js
Diff
commit 83016462369bb53d9b4ffd6ffbf4a4c3303165a9
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 20 15:53:12 2026 -0700
Individual client accounts (file-based, snapshot-safe): scrypt signup/login + stateless signed-cookie sessions; claim-to-account (claims link to user); /account page (signup/login/my-claims); Account nav link; broker page reflects sign-in. Behind the basic-auth wall (public flip = Steve-gated)
---
.deploy.conf | 2 +-
.gitignore | 2 ++
lib/accounts.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++
public/account.html | 59 +++++++++++++++++++++++++++++++++++++++++
public/broker-profile.html | 10 +++++++
public/index.html | 2 +-
server.js | 24 ++++++++++++++++-
7 files changed, 162 insertions(+), 3 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 266234d..3bc3756 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -7,4 +7,4 @@ HEALTH_URL=http://localhost:9714/api/health
INSTALL_CMD="npm ci --omit=dev"
BUILD_CMD=""
REQUIRED_ENVS="USE_SNAPSHOT" # prod runs USE_SNAPSHOT=1 (serves data/snapshot.json)
-RSYNC_EXTRA_EXCLUDES="tmp-server.log data/*.db data/claims.jsonl"
+RSYNC_EXTRA_EXCLUDES="tmp-server.log data/*.db data/claims.jsonl data/users.json data/.session-secret"
diff --git a/.gitignore b/.gitignore
index cd6627d..372af89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ tmp-*.png
tmp-shot.js
tmp-server.log
data/claims.jsonl
+data/users.json
+data/.session-secret
diff --git a/lib/accounts.js b/lib/accounts.js
new file mode 100644
index 0000000..e6ede3f
--- /dev/null
+++ b/lib/accounts.js
@@ -0,0 +1,66 @@
+'use strict';
+// Dependency-free, file-based accounts + sessions (snapshot/prod-safe — no DB).
+// Passwords: scrypt. Sessions: stateless signed cookie (survives restarts).
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const DATA = path.join(__dirname, '..', 'data');
+const USERS = path.join(DATA, 'users.json');
+const SECRET_FILE = path.join(DATA, '.session-secret');
+
+function secret() {
+ if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
+ try { return fs.readFileSync(SECRET_FILE, 'utf8'); }
+ catch { const s = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(SECRET_FILE, s); return s; }
+}
+const load = () => { try { return JSON.parse(fs.readFileSync(USERS, 'utf8')); } catch { return []; } };
+const save = (u) => fs.writeFileSync(USERS, JSON.stringify(u, null, 2));
+
+function hash(pw) { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 64).toString('hex'); }
+function verifyPw(pw, stored) {
+ try { const [salt, h] = stored.split(':'); const hh = crypto.scryptSync(pw, salt, 64).toString('hex');
+ return crypto.timingSafeEqual(Buffer.from(h, 'hex'), Buffer.from(hh, 'hex')); } catch { return false; }
+}
+function sign(id) { return id + '.' + crypto.createHmac('sha256', secret()).update(id).digest('hex'); }
+function verifyToken(tok) {
+ if (!tok || tok.indexOf('.') < 0) return null;
+ const i = tok.lastIndexOf('.'); const id = tok.slice(0, i), mac = tok.slice(i + 1);
+ const exp = crypto.createHmac('sha256', secret()).update(id).digest('hex');
+ try { return mac.length === exp.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(exp)) ? id : null; } catch { return null; }
+}
+const emailOk = (e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e || '');
+const byEmail = (email) => load().find(u => u.email.toLowerCase() === String(email).toLowerCase());
+const byId = (id) => load().find(u => u.id === id);
+
+function createUser({ email, password, name }) {
+ if (!emailOk(email)) return { error: 'valid email required' };
+ if (!password || password.length < 8) return { error: 'password must be at least 8 characters' };
+ const users = load();
+ if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) return { error: 'an account with that email already exists' };
+ const user = { id: crypto.randomBytes(9).toString('hex'), email: String(email).toLowerCase(),
+ name: String(name || '').slice(0, 120), pass: hash(password), role: 'client', claims: [], created_at: new Date().toISOString() };
+ users.push(user); save(users);
+ return { user };
+}
+function authenticate(email, password) {
+ const u = byEmail(email); if (!u || !verifyPw(password, u.pass)) return null; return u;
+}
+function addClaim(userId, brokerId) {
+ const users = load(); const u = users.find(x => x.id === userId); if (!u) return false;
+ if (!u.claims.includes(brokerId)) { u.claims.push(brokerId); save(users); } return true;
+}
+const publicUser = (u) => u ? { id: u.id, email: u.email, name: u.name, role: u.role, claims: u.claims } : null;
+
+// cookie helpers
+function parseCookies(req) {
+ const out = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); return out;
+}
+function currentUser(req) { const id = verifyToken(parseCookies(req).sess); return id ? byId(id) : null; }
+function setSession(res, req, userId) {
+ const secure = (req.headers['x-forwarded-proto'] === 'https' || req.secure) ? '; Secure' : '';
+ res.set('Set-Cookie', `sess=${sign(userId)}; HttpOnly; Path=/; Max-Age=2592000; SameSite=Lax${secure}`);
+}
+function clearSession(res) { res.set('Set-Cookie', 'sess=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); }
+
+module.exports = { createUser, authenticate, byId, byEmail, addClaim, publicUser, currentUser, setSession, clearSession, load };
diff --git a/public/account.html b/public/account.html
new file mode 100644
index 0000000..87f05cc
--- /dev/null
+++ b/public/account.html
@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Account · Sublease Marketplace</title>
+<style>
+ :root{--ink:#14181d;--mut:#6b7480;--line:#e7e9ee;--bg:#fbfbfc;--accent:#1a5f3c}
+ *{box-sizing:border-box}body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:var(--bg)}
+ a{color:var(--accent);text-decoration:none}.wrap{max-width:460px;margin:0 auto;padding:0 22px}
+ header.top{border-bottom:1px solid var(--line);background:#fff}.top .wrap{display:flex;align-items:center;height:60px;max-width:1200px}
+ .top a{color:var(--mut);font-size:14px}
+ .card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:26px;margin:34px auto}
+ h1{font-size:22px;margin:0 0 4px}.sub{color:var(--mut);margin:0 0 20px;font-size:14px}
+ .tabs{display:flex;gap:6px;margin-bottom:18px}
+ .tabs button{flex:1;padding:9px;border:1px solid var(--line);background:#fff;border-radius:8px;font:inherit;cursor:pointer;color:var(--mut)}
+ .tabs button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+ label{display:block;font-size:12.5px;color:var(--mut);margin:12px 0 4px}
+ input{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
+ .btn{width:100%;margin-top:18px;background:var(--accent);color:#fff;border:0;padding:11px;border-radius:9px;font:600 15px system-ui;cursor:pointer}
+ .msg{margin-top:12px;font-size:13.5px}.err{color:#b3261e}.ok{color:#1a7a44}
+ .claim{border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-top:8px;font-size:13.5px}
+ .muted{color:var(--mut)}
+</style></head><body>
+<header class="top"><div class="wrap"><a href="/">← marketplace</a></div></header>
+<div class="wrap"><div class="card" id="card"></div></div>
+<script>
+const $=s=>document.querySelector(s);
+async function me(){ return (await fetch('/api/auth/me').then(r=>r.json()).catch(()=>({}))).user; }
+async function render(){
+ const u=await me();
+ if(u){
+ $('#card').innerHTML=`<h1>My account</h1><p class="sub">${u.name?u.name+' · ':''}${u.email}</p>
+ <b style="font-size:14px">Profiles you've claimed</b>
+ ${(u.claims&&u.claims.length)?u.claims.map(id=>`<div class="claim">Broker <a href="/brokers/${id}">#${id}</a> <span class="muted">— pending review</span></div>`).join(''):'<p class="muted" style="font-size:13.5px">None yet. Open your broker profile and click <b>Claim this profile</b>.</p>'}
+ <button class="btn" id="logout" style="background:#eef2f7;color:#41505f;margin-top:20px">Log out</button>`;
+ $('#logout').onclick=async()=>{await fetch('/api/auth/logout',{method:'POST'});render();};
+ return;
+ }
+ $('#card').innerHTML=`<h1>Sign in</h1><p class="sub">Create an account to claim and manage your broker profile.</p>
+ <div class="tabs"><button id="tLogin" class="on">Log in</button><button id="tSignup">Sign up</button></div>
+ <form id="form">
+ <div id="nameWrap" style="display:none"><label>Name</label><input name="name" placeholder="Your name"></div>
+ <label>Email</label><input name="email" type="email" required>
+ <label>Password</label><input name="password" type="password" required minlength="8" placeholder="8+ characters">
+ <button class="btn" type="submit" id="submit">Log in</button>
+ <div class="msg" id="msg"></div>
+ </form>`;
+ let mode='login';
+ const setMode=m=>{mode=m;$('#tLogin').classList.toggle('on',m==='login');$('#tSignup').classList.toggle('on',m==='signup');
+ $('#nameWrap').style.display=m==='signup'?'block':'none';$('#submit').textContent=m==='signup'?'Create account':'Log in';};
+ $('#tLogin').onclick=()=>setMode('login'); $('#tSignup').onclick=()=>setMode('signup');
+ $('#form').onsubmit=async(e)=>{
+ e.preventDefault(); const fd=Object.fromEntries(new FormData(e.target).entries());
+ $('#msg').textContent='…';$('#msg').className='msg';
+ const r=await fetch('/api/auth/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(fd)}).then(r=>r.json());
+ if(r.ok){render();} else {$('#msg').className='msg err';$('#msg').textContent=r.error||'Failed';}
+ };
+}
+render();
+</script></body></html>
diff --git a/public/broker-profile.html b/public/broker-profile.html
index 8a909e1..df5edf5 100644
--- a/public/broker-profile.html
+++ b/public/broker-profile.html
@@ -71,6 +71,16 @@ const id=location.pathname.split('/').pop();
row('Specialties',b.specialties)+
(b.email?`<a class="cta" href="mailto:${b.email}">Contact ${b.name.split(' ')[0]}</a>`:'');
})();
+// reflect account state on the claim block
+fetch('/api/auth/me').then(r=>r.json()).then(m=>{
+ const block=document.querySelector('#claimBlock > div > div');
+ if(m.user){
+ if(block) block.innerHTML=`<b>Is this you?</b> <span class="muted">Signed in as ${m.user.email} — your claim will be linked to your account.</span>`;
+ const em=document.querySelector('#claimForm input[name=email]'); if(em){em.value=m.user.email;}
+ } else if(block){
+ block.innerHTML=`<b>Is this you?</b> <span class="muted">Claim this profile to keep it accurate. <a href="/account">Sign in</a> to link it to your account.</span>`;
+ }
+}).catch(()=>{});
document.getElementById('claimBtn').addEventListener('click',()=>{
const f=document.getElementById('claimForm'); f.style.display=f.style.display==='none'?'block':'none';
});
diff --git a/public/index.html b/public/index.html
index e94edb1..73a1e3d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -60,7 +60,7 @@
<body>
<header class="top"><div class="wrap">
<div class="brand">Sublease<small>Marketplace</small></div>
- <nav><a href="#map-sec">Map</a><a href="#listings">Listings</a><a href="#brokers">Brokers</a><a href="#sponsors">Sponsors</a><a href="/admin.html" id="adminLink" style="display:none">Admin</a></nav>
+ <nav><a href="#map-sec">Map</a><a href="#listings">Listings</a><a href="#brokers">Brokers</a><a href="#sponsors">Sponsors</a><a href="/account" id="acctLink">Account</a><a href="/admin.html" id="adminLink" style="display:none">Admin</a></nav>
</div></header>
<div class="hero"><div class="wrap">
diff --git a/server.js b/server.js
index 45e13c8..c7f031e 100644
--- a/server.js
+++ b/server.js
@@ -47,6 +47,25 @@ const requireAdmin = (req, res, next) => req.role === 'admin' ? next()
: res.status(403).send('<h2 style="font:600 18px system-ui;color:#b3261e;padding:40px">Admin access only</h2><p style="font:14px system-ui;padding:0 40px"><a href="/">← back to marketplace</a></p>');
app.get('/api/me', (req, res) => res.json({ user: req.user, role: req.role }));
+// ── Individual client accounts (file-based, snapshot-safe) — separate from the basic-auth wall ──
+const accounts = require('./lib/accounts');
+app.post('/api/auth/signup', (req, res) => {
+ const { email, password, name } = req.body || {};
+ const r = accounts.createUser({ email, password, name });
+ if (r.error) return res.status(400).json({ error: r.error });
+ accounts.setSession(res, req, r.user.id);
+ res.json({ ok: true, user: accounts.publicUser(r.user) });
+});
+app.post('/api/auth/login', (req, res) => {
+ const { email, password } = req.body || {};
+ const u = accounts.authenticate(email, password);
+ if (!u) return res.status(401).json({ error: 'invalid email or password' });
+ accounts.setSession(res, req, u.id);
+ res.json({ ok: true, user: accounts.publicUser(u) });
+});
+app.post('/api/auth/logout', (req, res) => { accounts.clearSession(res); res.json({ ok: true }); });
+app.get('/api/auth/me', (req, res) => res.json({ user: accounts.publicUser(accounts.currentUser(req)) }));
+
const LISTING_COLS = `l.id, l.listing_kind, l.is_sublease, l.title, l.address, l.city, l.state, l.zip, l.lat, l.lng,
l.space_type, l.asset_class, l.size_sf, l.units, l.cap_rate, l.year_built, l.price_amount, l.price_unit, l.term,
l.source_url, l.image_url, l.source_label, s.slug AS sponsor_slug, s.name AS sponsor_name, s.logo_path`;
@@ -168,8 +187,10 @@ app.post('/api/brokers/:id/claim', (req, res) => {
linkedin: clean(b.linkedin, 200), bio: clean(b.bio, 2000), corrections: clean(b.corrections, 2000),
is_self: !!b.is_self, ua: clean(req.headers['user-agent'], 200),
};
+ const acct = accounts.currentUser(req);
+ if (acct) { rec.account_id = acct.id; rec.account_email = acct.email; accounts.addClaim(acct.id, id); }
fs.appendFileSync(CLAIMS_FILE, JSON.stringify(rec) + '\n');
- res.json({ ok: true, message: 'Claim submitted for review.' });
+ res.json({ ok: true, message: 'Claim submitted for review.', linked_account: !!acct });
} catch (e) { res.status(500).json({ error: String(e) }); }
});
@@ -197,6 +218,7 @@ app.get('/api/stats', async (_q, res) => {
} catch (e) { res.status(500).json({ error: String(e) }); }
});
+app.get('/account', (_q, r) => r.sendFile(path.join(PUB, 'account.html')));
app.get('/broker/:slug', (_q, r) => r.sendFile(path.join(PUB, 'broker.html')));
app.get('/brokers/:id', (_q, r) => r.sendFile(path.join(PUB, 'broker-profile.html')));
// Admin-only pages — gate BEFORE express.static so a client can't fetch admin.html/claims.html directly
← 2aedc86 Role-based access: admin vs client. Backend gates /api/claim
·
back to Sublease Agentabrams
·
chore: lint, refactor, v0.2.0 (session close) — CENSUS_URL d 35f6650 →