← back to Commercialrealestate
CRCP: inline-city SSO — one hub login reaches all cities inline, no re-prompt (Steve directive)
c4ea66357a91d3c05fcc0d99d3a367b132c9c76f · 2026-08-20 14:23:56 -0700 · Steve Abrams
City subdomains (pasadena.crcp.agentabrams.com …) are HTTP-Basic-gated; recities/firecities are app-session.
Added an app-session-GATED reverse proxy in serve.js — /city/:name/* fetches the city with Basic creds injected
SERVER-SIDE (creds ONLY from env CITY_BASIC_AUTH, never hardcoded; proxy 503s if unset) + injects a <base>+fetch/XHR
shim so the page's root-absolute /api calls route back through the proxy — plus public/cities-inline.html (tabbed
inline hub via same-origin iframes). Rails: app-session required, strict city allowlist (no SSRF), GET-only.
Verified via direct run: /city/pasadena/ 200 (46KB real page) + /api/stats 200 JSON, allowlist 404, no-session 302.
Additive — did NOT touch crcp-agent's live recities.html.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A public/cities-inline.htmlM scripts/serve.js
Diff
commit c4ea66357a91d3c05fcc0d99d3a367b132c9c76f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 20 14:23:56 2026 -0700
CRCP: inline-city SSO — one hub login reaches all cities inline, no re-prompt (Steve directive)
City subdomains (pasadena.crcp.agentabrams.com …) are HTTP-Basic-gated; recities/firecities are app-session.
Added an app-session-GATED reverse proxy in serve.js — /city/:name/* fetches the city with Basic creds injected
SERVER-SIDE (creds ONLY from env CITY_BASIC_AUTH, never hardcoded; proxy 503s if unset) + injects a <base>+fetch/XHR
shim so the page's root-absolute /api calls route back through the proxy — plus public/cities-inline.html (tabbed
inline hub via same-origin iframes). Rails: app-session required, strict city allowlist (no SSRF), GET-only.
Verified via direct run: /city/pasadena/ 200 (46KB real page) + /api/stats 200 JSON, allowlist 404, no-session 302.
Additive — did NOT touch crcp-agent's live recities.html.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/cities-inline.html | 34 ++++++++++++++++++++++++++++++++++
scripts/serve.js | 33 +++++++++++++++++++++++++++++++++
2 files changed, 67 insertions(+)
diff --git a/public/cities-inline.html b/public/cities-inline.html
new file mode 100644
index 0000000..8565a36
--- /dev/null
+++ b/public/cities-inline.html
@@ -0,0 +1,34 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>CRCP · Cities (inline)</title>
+<!-- Steve 2026-08-20: one login (steve/DW2024!) -> every city INLINE, no admin/DW2024! re-prompt.
+ Each tab loads /city/<name>/ which the server proxies with the Basic creds injected server-side. -->
+<style>
+ :root{--bg:#0e1116;--panel:#161b22;--edge:#232a33;--ink:#e6edf3;--mut:#8b949e;--accent:#1f6feb}
+ *{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);font-family:Inter,-apple-system,Helvetica,Arial,sans-serif}
+ body{display:flex;flex-direction:column}
+ header{display:flex;align-items:center;gap:14px;padding:10px 18px;border-bottom:1px solid var(--edge);background:var(--panel)}
+ header b{font-size:15px} header .who{color:var(--mut);font-size:12px;margin-left:auto}
+ nav{display:flex;gap:6px;flex-wrap:wrap;padding:9px 18px;border-bottom:1px solid var(--edge);background:var(--panel)}
+ nav button{background:var(--bg);color:var(--mut);border:1px solid var(--edge);border-radius:20px;padding:6px 14px;font-size:12.5px;cursor:pointer;text-transform:capitalize}
+ nav button:hover{color:var(--ink);border-color:var(--accent)}
+ nav button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+ .frame-wrap{flex:1;position:relative;min-height:0}
+ iframe{position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff}
+ .loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:13px;pointer-events:none}
+</style></head><body>
+<header><b>◈ CRCP · Cities</b><span class="who" id="who">one login · all cities inline</span></header>
+<nav id="tabs"></nav>
+<div class="frame-wrap"><div class="loading" id="load">loading…</div><iframe id="frame" title="city"></iframe></div>
+<script>
+const CITIES=['pasadena','glendale','burbank','santa-monica','arcadia','torrance','long-beach','van-nuys'];
+const tabs=document.getElementById('tabs'), frame=document.getElementById('frame'), load=document.getElementById('load');
+// require the app session; the /city/* proxy also enforces it, this just gives a clean redirect
+fetch('/api/me').then(r=>r.json()).then(m=>{ if(!m||!m.email){ location='/login.html?next=/cities-inline.html'; return; }
+ document.getElementById('who').textContent='signed in · '+m.email+' · all cities inline'; });
+frame.addEventListener('load',()=>{ load.style.display='none'; });
+function open(c,btn){ [...tabs.children].forEach(x=>x.classList.remove('on')); btn.classList.add('on'); load.style.display='flex'; frame.src='/city/'+c+'/'; location.hash=c; }
+CITIES.forEach((c,i)=>{ const b=document.createElement('button'); b.textContent=c.replace(/-/g,' '); b.onclick=()=>open(c,b); tabs.appendChild(b); });
+const start=(location.hash||'').slice(1); const idx=Math.max(0,CITIES.indexOf(start));
+open(CITIES[idx], tabs.children[idx]);
+</script></body></html>
diff --git a/scripts/serve.js b/scripts/serve.js
index baedb92..dbce874 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -97,6 +97,39 @@ app.get(/(?:^\/$|\.html$)/, (req, res, next) => {
// ── P1 subscription layer: accounts + saved searches + watchlist (docs/TOOL-SPEC.md) ──
try { acct(app, ROOT); require('./crcp-billing')(app, ROOT, acct.userOf); require('./crcp-leads')(app, ROOT, acct.userOf); require('./crcp-export')(app, ROOT, acct.userOf); require('./crcp-notes')(app, ROOT, acct.userOf); } catch (e) { console.error('[crcp-accounts/billing/leads/export/notes] mount failed:', e.message); }
+// ── inline-city SSO proxy: /city/:name/* (Steve 2026-08-20: one hub login -> cities INLINE, no re-prompt) ──
+// The city subdomains (pasadena.crcp.agentabrams.com …) are HTTP-Basic-gated (creds via env); recities/firecities
+// are app-session. This proxies a city behind the APP SESSION so a signed-in user reaches it same-origin (inline
+// iframe) without ever seeing the Basic creds. HARD RAILS: app-session required · strict city allowlist (no SSRF) ·
+// GET-only · creds from env (CITY_BASIC_AUTH). The page's root-absolute /api/* calls are re-routed via a shim.
+// Credentials come ONLY from the env (CITY_BASIC_AUTH="user:pass") — never hardcoded. Proxy disabled if unset.
+const CITY_BASIC = process.env.CITY_BASIC_AUTH ? ('Basic ' + Buffer.from(process.env.CITY_BASIC_AUTH).toString('base64')) : null;
+const CITY_ALLOW = new Set((process.env.CITY_ALLOW || 'pasadena,glendale,burbank,santa-monica,arcadia,torrance,long-beach,van-nuys').split(',').map(s => s.trim()).filter(Boolean));
+app.use('/city/:name', async (req, res) => { // app.use prefix-mount matches /city/<name> AND all sub-paths reliably
+ if (req.method !== 'GET') return res.status(405).send('GET only');
+ if (!CITY_BASIC) return res.status(503).send('city proxy not configured (set CITY_BASIC_AUTH)');
+ const u = (typeof acct.userOf === 'function') ? acct.userOf(req) : null;
+ if (!u) return res.redirect('/login.html?next=' + encodeURIComponent(req.originalUrl)); // app session required
+ const name = String(req.params.name || '').toLowerCase();
+ if (!CITY_ALLOW.has(name)) return res.status(404).send('unknown city'); // strict allowlist -> no SSRF
+ const url = `https://${name}.crcp.agentabrams.com${req.url || '/'}`; // req.url = sub-path (+query) after the mount
+ try {
+ const up = await fetch(url, { headers: { Authorization: CITY_BASIC } });
+ const ct = up.headers.get('content-type') || '';
+ res.status(up.status);
+ up.headers.forEach((v, k) => { if (!/^(content-encoding|transfer-encoding|content-length|content-security-policy|x-frame-options|set-cookie|strict-transport-security)$/i.test(k)) res.set(k, v); });
+ res.set('Content-Security-Policy', "frame-ancestors 'self'"); // allow same-origin iframe
+ if (/text\/html/i.test(ct)) {
+ let html = await up.text();
+ const shim = `<base href="/city/${name}/"><script>(function(){var P='/city/${name}/';function fix(u){return (typeof u==='string'&&u.charAt(0)==='/'&&u.indexOf(P)!==0)?P+u.slice(1):u;}if(window.fetch){var of=window.fetch;window.fetch=function(u,o){return of.call(this,fix(u),o);};}var ox=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){return ox.apply(this,[m,fix(u)].concat([].slice.call(arguments,2)));};})();<\/script>`;
+ html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + shim) : (shim + html);
+ res.send(html);
+ } else {
+ res.send(Buffer.from(await up.arrayBuffer()));
+ }
+ } catch (e) { res.status(502).send('city unavailable'); }
+});
+
// ── Agent-contact CRM (durable, local JSON) ─────────────────────────────────────────
// One record per listing id: editable {name,phone,email}, a `contacted_at` stamp, and an
// append-only `letters[]` log (every letter Steve writes to the agent is SAVED here — sending
← c56a993 CRCP scope: sameCity strips trailing state suffix ('City, CA
·
back to Commercialrealestate
·
CRCP scope Cycle 4: derive city from address for direct-list a9088ee →