[object Object]

← 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

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 →