← back to Commercialrealestate
CRCP: harden auth surface (yolo iter 1) — login rate-limit + form semantics + ticker a11y
6aeef2885b5648e0e14833fdc56021d61071aff1 · 2026-07-31 11:18:09 -0700 · Steve
- /auth/login: in-memory per-(ip|username) brute-force throttle, lock after 8 fails for 5min, reset on success (verified: 9th attempt -> 429, other users unaffected)
- deals-flow.html: login modal wrapped in a real <form> (password-manager + Enter + role=dialog/aria), Escape-to-close, magic-link fallback keyboard-accessible
- ticker: duplicated headline half marked aria-hidden (SR no longer reads every item twice), ticker region aria-labelled — deals-flow + just-listed
- re-verified headless: 0 console errors, login/persona/ticker/just-listed all functional
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/deals-flow.htmlM public/just-listed.htmlM scripts/crcp-accounts.js
Diff
commit 6aeef2885b5648e0e14833fdc56021d61071aff1
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 31 11:18:09 2026 -0700
CRCP: harden auth surface (yolo iter 1) — login rate-limit + form semantics + ticker a11y
- /auth/login: in-memory per-(ip|username) brute-force throttle, lock after 8 fails for 5min, reset on success (verified: 9th attempt -> 429, other users unaffected)
- deals-flow.html: login modal wrapped in a real <form> (password-manager + Enter + role=dialog/aria), Escape-to-close, magic-link fallback keyboard-accessible
- ticker: duplicated headline half marked aria-hidden (SR no longer reads every item twice), ticker region aria-labelled — deals-flow + just-listed
- re-verified headless: 0 console errors, login/persona/ticker/just-listed all functional
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/deals-flow.html | 24 +++++++++++++-----------
public/just-listed.html | 4 ++--
scripts/crcp-accounts.js | 18 ++++++++++++++++++
3 files changed, 33 insertions(+), 13 deletions(-)
diff --git a/public/deals-flow.html b/public/deals-flow.html
index 33a3c56..e2d928d 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -88,7 +88,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
<div class="nav"><a href="/just-listed.html">Just Listed</a><a href="/fha-leads.html">Assumable Leads</a><a href="/mls.html">Listings</a><a href="/">Explorer</a></div>
<span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
</header>
-<div class="ticker" id="ticker" style="display:none"><div class="lbl">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
+<div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
<div class="shell">
<aside class="rail">
<div class="rsec" id="savedSec" style="display:none"><h4>My saved searches</h4><div class="chips" id="fSaved"></div></div>
@@ -115,15 +115,17 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
</div></div>
<!-- sign-in modal (username + password) -->
-<div class="modal" id="loginModal">
+<div class="modal" id="loginModal" role="dialog" aria-modal="true" aria-label="Sign in to CRCP">
<div class="box">
<h3>Sign in to CRCP</h3>
<p>Your personal deal-flow — saved searches, watchlist, and a home tuned to your business.</p>
- <label>Username</label><input id="liUser" autocomplete="username" placeholder="frank">
- <label>Password</label><input id="liPass" type="password" autocomplete="current-password" placeholder="••••••••">
- <div class="err" id="liErr"></div>
- <button class="btn" id="liBtn">Sign in</button>
- <span class="alt" id="liMagic">Prefer an email magic-link instead?</span>
+ <form id="loginForm">
+ <label for="liUser">Username</label><input id="liUser" name="username" autocomplete="username" placeholder="frank" aria-label="Username">
+ <label for="liPass">Password</label><input id="liPass" name="password" type="password" autocomplete="current-password" placeholder="••••••••" aria-label="Password">
+ <div class="err" id="liErr" role="alert" aria-live="polite"></div>
+ <button class="btn" id="liBtn" type="submit">Sign in</button>
+ </form>
+ <span class="alt" id="liMagic" role="button" tabindex="0">Prefer an email magic-link instead?</span>
</div>
</div>
@@ -244,11 +246,11 @@ function openLogin(){$('#liErr').textContent='';$('#liUser').value='';$('#liPass
const signIn=openLogin; // back-compat: save/watch/star gates call signIn() to prompt sign-in
function closeLogin(){$('#loginModal').classList.remove('on');}
async function doLogin(){const username=$('#liUser').value.trim(),password=$('#liPass').value;if(!username||!password){$('#liErr').textContent='Enter username and password.';return;}$('#liErr').textContent='…';const r=await api('/auth/login',{method:'POST',body:JSON.stringify({username,password})}).catch(()=>null);if(r&&r.ok){closeLogin();location.reload();}else{$('#liErr').textContent=(r&&r.error)||'Sign-in failed.';}}
-$('#liBtn').onclick=doLogin;
-$('#liUser').addEventListener('keydown',e=>{if(e.key==='Enter')$('#liPass').focus();});
-$('#liPass').addEventListener('keydown',e=>{if(e.key==='Enter')doLogin();});
+$('#loginForm').addEventListener('submit',e=>{e.preventDefault();doLogin();});
$('#liMagic').onclick=magicSignIn;
+$('#liMagic').addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();magicSignIn();}});
$('#loginModal').addEventListener('click',e=>{if(e.target.id==='loginModal')closeLogin();});
+document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLogin();});
// magic-link fallback (kept for email accounts)
async function magicSignIn(){const email=prompt('Email for a magic-link sign-in:');if(!email)return;const r=await api('/auth/request',{method:'POST',body:JSON.stringify({email})});if(r.magic_link){if(confirm('Dev mode — open your magic-link to sign in now?'))location.href=r.magic_link;}else alert('Check your email for a sign-in link.');}
// ── first-run industry / role picker ──
@@ -257,7 +259,7 @@ function openRole(){_roleSel=(ME&&ME.role)||null;$('#roleErr').textContent='';$(
async function saveRole(){if(!_roleSel)return;const r=await api('/auth/profile',{method:'POST',body:JSON.stringify({role:_roleSel})}).catch(()=>null);if(r&&r.ok){ME=r.me;$('#roleModal').classList.remove('on');renderAuth();renderPersona();}else{$('#roleErr').textContent=(r&&r.error)||'Could not save.';}}
$('#roleBtn').onclick=saveRole;
// ── breaking-news ticker ──
-async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');$('#tickTrack').innerHTML=html+html;$('#ticker').style.display='';}catch(e){}}
+async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
loadTicker();
async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip" data-sid="${x.id}" title="apply this search">${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
async function loadWatch(){const r=await api('/api/watchlist');WATCH=new Set(r.ids||[]);render();}
diff --git a/public/just-listed.html b/public/just-listed.html
index c4edd65..cd55d72 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -60,7 +60,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
<div class="nav"><a href="/deals-flow.html">Deal Flow</a><a href="/mls.html">Ranked Deals</a><a href="/">Explorer</a></div>
<span id="auth" style="margin-left:auto;font-size:12px;color:var(--blue);cursor:pointer">Sign in</span>
</header>
-<div class="ticker" id="ticker" style="display:none"><div class="lbl">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
+<div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
<div class="shell">
<aside class="rail">
<div class="rsec"><h4>Status</h4><div class="chips" id="fStatus"></div></div>
@@ -169,7 +169,7 @@ const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/jso
function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role?'':'';el.innerHTML=`<b style="color:#fff">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
api('/api/me').then(m=>{ME=m;renderAuth();}).catch(()=>{});
// ── breaking-news ticker ──
-async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');$('#tickTrack').innerHTML=html+html;$('#ticker').style.display='';}catch(e){}}
+async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
loadTicker();
// ── load active inventory ──
fetch('/data/listings.json').then(r=>r.json()).then(d=>{
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 994e82d..cdf788a 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -77,15 +77,33 @@ module.exports = function mountAccounts(app, ROOT) {
// 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;
+ const gc = () => { for (const [k, v] of _fails) if (v.until && v.until < now()) _fails.delete(k); };
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];
if (!u || !u.passhash || !verifyPw(b.password, u.salt, u.passhash)) {
+ const rec = f && f.until <= now() ? { n: 0 } : (f || { n: 0 }); // reset a lapsed lock
+ rec.n = (rec.n || 0) + 1;
+ 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);
← ba57a25 crcp Panel 7: add 6/12/24-month window toggle (persisted, re
·
back to Commercialrealestate
·
condos detail modal: broker/firm fields drill to crcp (TK-10 653c5c9 →