[object Object]

← back to Rentv

RENTV APIs: add injection-safe ?sort=&order= to /api/deals, /api/articles, /api/brokers, /api/firms, /api/sublease (strict per-route whitelist; proxy rows sorted server-side; default order preserved)

de764576b53829313067e9b13c03fb39ba6a6805 · 2026-08-13 08:25:07 -0700 · steve

Files touched

Diff

commit de764576b53829313067e9b13c03fb39ba6a6805
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:25:07 2026 -0700

    RENTV APIs: add injection-safe ?sort=&order= to /api/deals, /api/articles, /api/brokers, /api/firms, /api/sublease (strict per-route whitelist; proxy rows sorted server-side; default order preserved)
---
 public/crcp-grid.css        | 110 ++++++++++++
 public/crcp-grid.js         | 419 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/li-archive-fetch.js |  39 +++++
 scripts/li-archive-poll.sh  |  36 ++++
 server.js                   |  61 ++++++-
 5 files changed, 659 insertions(+), 6 deletions(-)

diff --git a/public/crcp-grid.css b/public/crcp-grid.css
new file mode 100644
index 00000000..9071c95c
--- /dev/null
+++ b/public/crcp-grid.css
@@ -0,0 +1,110 @@
+/* crcp-grid.css — styles for the reusable CrcpGrid engine. Namespaced .cg-* so it never
+ * collides with a host page. Colors read the host's CSS vars with safe fallbacks, so the
+ * engine inherits each page's theme (condos dark, fha-loans glass, sales gold) automatically. */
+.cg-shell{display:flex;align-items:flex-start;}
+.cg-rail{flex:0 0 250px;align-self:stretch;border-right:1px solid var(--cg-line,#2a313c);background:var(--cg-railbg,rgba(0,0,0,.18));position:sticky;top:0;max-height:100vh;overflow-y:auto;padding-bottom:30px;}
+.cg-main{flex:1 1 auto;min-width:0;--cg-cols:3;}
+.cg-sec{padding:12px 15px;border-bottom:1px solid var(--cg-line,#2a313c);}
+.cg-sec h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--cg-mut,#8b949e);cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;}
+.cg-sec h4::after{content:'▾';font-size:11px;margin-left:8px;transition:transform .15s;}
+.cg-sec.collapsed h4::after{transform:rotate(-90deg);}
+.cg-sec.collapsed > *:not(h4){display:none !important;}
+.cg-chips{display:flex;flex-wrap:wrap;gap:6px;}
+.cg-chip{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
+.cg-chip:hover{color:var(--cg-ink,#e6edf3);}
+.cg-chip.active{border-color:var(--cg-acc,#58a6ff);color:var(--cg-acc,#58a6ff);background:var(--cg-accbg,rgba(88,166,255,.10));}
+.cg-ct{color:var(--cg-mut,#8b949e);font-size:10px;margin-left:4px;}
+.cg-row2{display:flex;gap:8px;}
+.cg-fld{flex:1;}
+.cg-fld label{display:block;font-size:10px;color:var(--cg-mut,#8b949e);margin-bottom:3px;}
+.cg-fld input{width:100%;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:7px 9px;font-size:13px;}
+.cg-ftogs{display:flex;flex-direction:column;gap:1px;max-height:300px;overflow-y:auto;}
+.cg-ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--cg-ink,#e6edf3);cursor:pointer;padding:3px 4px;border-radius:6px;}
+.cg-ftog:hover{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-mini{margin-top:8px;display:flex;gap:5px;}
+.cg-mini button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;}
+.cg-mini button:hover{border-color:var(--cg-acc,#58a6ff);color:var(--cg-ink,#e6edf3);}
+.cg-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:13px 18px;border-bottom:1px solid var(--cg-line,#2a313c);position:sticky;top:0;background:var(--cg-topbg,rgba(14,17,22,.94));backdrop-filter:blur(6px);z-index:6;}
+/* active-filter summary bar (removable chips + clear all) */
+.cg-active{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 18px 0;}
+.cg-active:empty{display:none;}
+.cg-af-lbl{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--cg-mut,#8b949e);}
+.cg-af{display:inline-flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;
+  background:var(--cg-accbg,rgba(200,162,75,.12));color:var(--cg-ink,#e6edf3);
+  border:1px solid var(--cg-acc,#58a6ff);border-radius:14px;padding:3px 10px;white-space:nowrap;}
+.cg-af b{color:var(--cg-acc,#58a6ff);font-weight:700;}
+.cg-af:hover{background:var(--cg-acc,#58a6ff);color:var(--cg-railbg,#0b0e13);}
+.cg-af:hover b{color:var(--cg-railbg,#0b0e13);}
+.cg-af-clear{font-size:12px;background:none;border:0;cursor:pointer;padding:3px 6px;
+  color:var(--cg-mut,#8b949e);text-decoration:underline;}
+.cg-af-clear:hover{color:var(--cg-acc,#58a6ff);}
+.cg-af-copy{font-size:12px;background:none;border:1px solid var(--cg-line,#2a313c);cursor:pointer;padding:3px 10px;
+  border-radius:8px;color:var(--cg-mut,#8b949e);}
+.cg-af-copy:hover{color:var(--cg-acc,#58a6ff);border-color:var(--cg-acc,#58a6ff);}
+.cg-q{min-width:240px;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 11px;font-size:13px;}
+.cg-seg{display:inline-flex;border:1px solid var(--cg-line,#2a313c);border-radius:8px;overflow:hidden;}
+.cg-seg button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:0;border-right:1px solid var(--cg-line,#2a313c);padding:7px 12px;font-size:12px;cursor:pointer;}
+.cg-seg button:last-child{border-right:0;}
+.cg-seg button.active{background:var(--cg-acc,#1f6feb);color:#fff;}
+.cg-dens{font-size:12px;color:var(--cg-mut,#8b949e);display:flex;align-items:center;gap:7px;}
+.cg-dens input{accent-color:var(--cg-acc,#58a6ff);}
+.cg-count{margin-left:auto;font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-count b{color:var(--cg-ink,#e6edf3);}
+.cg-grid{display:grid;gap:16px;padding:18px;grid-template-columns:repeat(var(--cg-cols,3),minmax(0,1fr));}
+.cg-grid.cg-listhost{display:block;}
+.cg-card{background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:12px;padding:15px;display:flex;flex-direction:column;gap:5px;}
+.cg-card.red{border-color:var(--cg-red,#f85149);}
+.cg-name{font-weight:600;font-size:14px;color:var(--cg-ink,#e6edf3);}
+.cg-sub{font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-r{display:flex;justify-content:space-between;font-size:12px;gap:10px;}
+.cg-r .cg-k{color:var(--cg-mut,#8b949e);}
+.cg-empty{padding:64px 24px;text-align:center;color:var(--cg-mut,#8b949e);display:flex;flex-direction:column;align-items:center;gap:8px;}
+.cg-empty-ic{font-size:40px;line-height:1;opacity:.5;}
+.cg-empty-t{font-size:18px;font-weight:700;color:var(--cg-ink,#e6edf3);}
+.cg-empty-s{font-size:13px;}
+.cg-empty-btn{margin-top:10px;text-decoration:none;background:var(--cg-acc,#58a6ff);color:var(--cg-railbg,#0b0e13);
+  border:0;border-radius:8px;padding:9px 18px;font-size:13px;font-weight:700;cursor:pointer;}
+.cg-empty-btn:hover{filter:brightness(1.08);}
+.cg-listwrap{overflow-x:auto;margin:18px;border:1px solid var(--cg-line,#2a313c);border-radius:12px;}
+.cg-tbl{width:100%;border-collapse:collapse;font-size:12.5px;white-space:nowrap;}
+.cg-tbl th,.cg-tbl td{text-align:right;padding:9px 13px;border-top:1px solid var(--cg-line,#2a313c);}
+.cg-tbl th{position:sticky;top:0;background:var(--cg-railbg,#0b0e13);color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:0;}
+.cg-tbl td.cg-stick,.cg-tbl th.cg-stick{text-align:left;position:sticky;left:0;background:var(--cg-card,#161b22);}
+.cg-tbl th.cg-stick{background:var(--cg-railbg,#0b0e13);}
+.cg-tbl tr:hover td{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-tbl tr.red td{background:rgba(248,81,73,.07);}
+.cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);}
+.cg-lc{color:var(--cg-mut,#8b949e);font-size:11px;}
+/* mobile filter access: rail becomes an off-canvas drawer opened by the .cg-filt-btn */
+.cg-filt-btn{display:none;align-items:center;gap:6px;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);
+  border:1px solid var(--cg-acc,#58a6ff);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;white-space:nowrap;}
+.cg-rail-backdrop{position:fixed;inset:0;z-index:1250;background:rgba(0,0,0,.5);opacity:0;visibility:hidden;transition:opacity .2s;}
+.cg-rail-backdrop.open{opacity:1;visibility:visible;}
+@media(max-width:820px){
+  .cg-filt-btn{display:inline-flex;}
+  .cg-top{padding-left:66px;} /* clear the fixed upper-left hamburger */
+  .cg-rail{position:fixed;top:0;left:0;height:100%;width:290px;max-width:86vw;z-index:1300;
+    transform:translateX(-100%);transition:transform .22s ease;box-shadow:2px 0 22px rgba(0,0,0,.55);}
+  .cg-rail.open{transform:translateX(0);}
+}
+/* --- USRE adaptations (nationalrealestate) --- */
+.cg-sort{background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 10px;font-size:13px;}
+.cg-sortth{cursor:pointer;user-select:none;}
+.cg-sortth:hover{color:var(--cg-ink,#e6edf3);}
+/* density slider drives list-view row padding + font size via --cg-dens (1..12) */
+.cg-tbl{font-size:calc(15.5px - var(--cg-dens,3)*0.45px);font-variant-numeric:tabular-nums;}
+.cg-tbl th,.cg-tbl td{padding:calc(14px - var(--cg-dens,3)*1px) calc(16px - var(--cg-dens,3)*0.8px);}
+.cg-tbl tr[data-ri]{cursor:pointer;}
+.cg-tbl tr.dim td,.cg-card.dim{opacity:.45;}
+/* --- compact LIST view (one scannable row per record; density-aware) --- */
+.cg-rows{display:flex;flex-direction:column;gap:calc(10px - var(--cg-dens,3)*0.9px);padding:16px;}
+.cg-lrow{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:10px;padding:calc(13px - var(--cg-dens,3)*0.8px) 14px;cursor:pointer;}
+.cg-lrow:hover{border-color:var(--cg-acc,#58a6ff);}
+.cg-lrow.red{border-color:var(--cg-red,#f85149);}
+.cg-lrow.dim{opacity:.45;}
+.cg-lmain{flex:0 0 clamp(180px,22vw,280px);min-width:180px;display:flex;flex-direction:column;gap:2px;}
+.cg-lmain .cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);font-size:calc(15px - var(--cg-dens,3)*0.25px);}
+.cg-lsub{color:var(--cg-mut,#8b949e);font-size:11.5px;}
+.cg-lmeta{flex:1 1 auto;min-width:0;display:flex;flex-wrap:wrap;gap:5px 18px;align-items:baseline;font-size:calc(13.5px - var(--cg-dens,3)*0.4px);font-variant-numeric:tabular-nums;color:var(--cg-ink,#e6edf3);}
+.cg-lm{white-space:nowrap;}
+.cg-lmk{color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-right:4px;}
diff --git a/public/crcp-grid.js b/public/crcp-grid.js
new file mode 100644
index 00000000..574bd9a8
--- /dev/null
+++ b/public/crcp-grid.js
@@ -0,0 +1,419 @@
+/* crcp-grid.js — reusable Amazon-style faceted grid engine for the CRE fleet.
+ * Config-driven, theme-agnostic (inherits the host page's CSS vars via cg-grid.css).
+ * One page = one CrcpGrid.mount(opts) call. Mirrors the proven condos.html engine so
+ * every list page gets the SAME behavior: left-rail facets, per-field show/hide toggles,
+ * grid / list / table view toggle, density slider, live search, localStorage persistence.
+ * Three view modes: 'grid' (image/data cards), 'list' (one compact scannable row per
+ * record), 'table' (dense all-columns spreadsheet with click-to-sort headers). EVERY
+ * non-badge field is sortable via the topbar Sort select (both directions).
+ *
+ * opts = {
+ *   data:      [row,...],            // all rows (client-side faceting)
+ *   fields:    [{k,l,def,col,badge,money,num,date,calc,suf,pct}],  // every data point (card rows + list cols)
+ *   facets:    [{k,l,type:'chip'|'range',calc,order,max}],         // rail filters (auto-derived if omitted)
+ *   mount:     '#cgMain',            // container for topbar + grid
+ *   rail:      '#cgRail',            // container for the left rail
+ *   title:     row => ({name, sub}),// card/list title + subline
+ *   search:    row => 'text blob',   // searchable string
+ *   badgeHtml: row => '<span>...'|'',// optional status badge html (card + list)
+ *   storageKey:'fha',               // localStorage namespace
+ *   cols:      3,                   // default density
+ *   onRow:     row => ({cls}),      // optional per-row class hook (e.g. red flag)
+ *   onRowClick:row => {},           // optional whole-row click handler (list + cards)
+ *   sort:      {k:'score',dir:-1},  // default sort (USRE adaptation; persisted, select + th-click)
+ * }
+ * USRE adaptations (nationalrealestate): sort <select> in the topbar + clickable list-view
+ * column headers (both persisted), density slider also drives list row padding/font via
+ * --cg-dens, onRowClick hook. Engine otherwise identical to the CRCP original.
+ */
+(function (global) {
+  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+  const $ = (sel, root) => (root || document).querySelector(sel);
+
+  function fmt(v, f) {
+    if (v == null || v === '') return '—';
+    if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
+    if (f.pct)   return (+v).toFixed(2) + '%';
+    if (f.num)   return (+v).toLocaleString() + (f.suf || '');
+    if (f.date)  { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
+    return esc(v) + (f.suf || '');
+  }
+
+  function CrcpGrid(opts) {
+    const O = opts;
+    const KEY = O.storageKey || 'cg';
+    const fields = O.fields;
+    const data = O.data || [];
+    const fval = (row, f) => f.calc ? f.calc(row) : row[f.k];
+
+    // ---- field visibility (persisted) ----
+    const VIS = (function () {
+      let s = {}; try { s = JSON.parse(localStorage.getItem(KEY + 'Fields') || '{}'); } catch (e) {}
+      const o = {}; fields.forEach(f => o[f.k] = (f.k in s) ? !!s[f.k] : !!f.def); return o;
+    })();
+    const saveVis = () => { try { localStorage.setItem(KEY + 'Fields', JSON.stringify(VIS)); } catch (e) {} };
+
+    // ---- auto-derive facets if not provided ----
+    let facets = O.facets;
+    if (!facets) {
+      facets = [];
+      fields.forEach(f => {
+        if (f.badge || f.k === 'title') return;
+        const vals = data.map(r => fval(r, f)).filter(v => v != null && v !== '');
+        if (!vals.length) return;
+        const numeric = f.money || f.num || f.pct || vals.every(v => typeof v === 'number' || /^\d+(\.\d+)?$/.test(v));
+        const uniq = new Set(vals.map(String));
+        if (!numeric && uniq.size <= 24) facets.push({ k: f.k, l: f.l, type: 'chip', calc: f.calc });
+        else if (numeric) facets.push({ k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
+      });
+    }
+
+    // ---- sort state (persisted) ----
+    let SORT = O.sort || null; // {k, dir:1|-1}; k='' = source order
+    try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
+    const saveSort = () => { try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e) {} };
+    function sortRows(rows) {
+      if (!SORT || !SORT.k) return rows;
+      const f = fields.find(x => x.k === SORT.k);
+      if (!f) return rows;
+      const dir = SORT.dir || 1;
+      return [...rows].sort((a, b) => {
+        const va = fval(a, f), vb = fval(b, f);
+        const na = va == null || va === '', nb = vb == null || vb === '';
+        if (na && nb) return 0; if (na) return 1; if (nb) return -1; // nulls last either dir
+        if (typeof va === 'number' || typeof vb === 'number' || f.money || f.num || f.pct)
+          return ((+va) - (+vb)) * dir;
+        return String(va).localeCompare(String(vb)) * dir;
+      });
+    }
+
+    // ---- filter state ----
+    const F = { q: '', chips: {}, ranges: {} };
+    facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k] = new Set(); else F.ranges[fc.k] = { min: null, max: null }; });
+
+    const gv = (row, fc) => fc.calc ? fc.calc(row) : row[fc.k];
+    function pass(row) {
+      for (const fc of facets) {
+        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set.size && !set.has(String(gv(row, fc)))) return false; }
+        else { const r = F.ranges[fc.k], v = gv(row, fc);
+          if (r.min != null && !(+v >= r.min)) return false;
+          if (r.max != null && !(+v <= r.max)) return false; }
+      }
+      if (F.q) { const blob = (O.search ? O.search(row) : fields.map(f => fval(row, f)).join(' ')).toLowerCase(); if (!blob.includes(F.q)) return false; }
+      return true;
+    }
+
+    // ---- URL sync: filters/search/sort <-> query string (shareable views) ----
+    function readFromURL() {
+      if (O.urlSync === false) return;
+      const sp = new URLSearchParams(location.search);
+      if (![...sp.keys()].length) return;
+      facets.forEach(fc => {
+        if (fc.type === 'chip') { const v = sp.get(fc.k); if (v) v.split(',').forEach(x => F.chips[fc.k].add(x)); }
+        else {
+          const mn = sp.get(fc.k + '_min'), mx = sp.get(fc.k + '_max');
+          if (mn != null && mn !== '') F.ranges[fc.k].min = +mn;
+          if (mx != null && mx !== '') F.ranges[fc.k].max = +mx;
+        }
+      });
+      const q = sp.get('q'); if (q) F.q = q.toLowerCase();
+      const so = sp.get('sort'); if (so) { const [k, d] = so.split(':'); if (k) SORT = { k, dir: d === 'asc' ? 1 : -1 }; }
+    }
+    function syncURL() {
+      if (O.urlSync === false) return;
+      const sp = new URLSearchParams();
+      facets.forEach(fc => {
+        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set && set.size) sp.set(fc.k, [...set].join(',')); }
+        else { const r = F.ranges[fc.k]; if (r) { if (r.min != null) sp.set(fc.k + '_min', r.min); if (r.max != null) sp.set(fc.k + '_max', r.max); } }
+      });
+      if (F.q) sp.set('q', F.q);
+      if (SORT && SORT.k) sp.set('sort', SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc'));
+      const qs = sp.toString();
+      try { history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '') + location.hash); } catch (e) {}
+    }
+
+    // ---- rail ----
+    function buildRail() {
+      const rail = $(O.rail);
+      let html = '';
+      facets.forEach(fc => {
+        if (fc.type === 'chip') {
+          const counts = {};
+          data.forEach(r => { const v = gv(r, fc); if (v != null && v !== '') counts[v] = (counts[v] || 0) + 1; });
+          const ent = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, fc.max || 20);
+          if (!ent.length) return;
+          // render FROM F: mark active chips + auto-expand a section that has a live filter
+          const set = F.chips[fc.k];
+          const secCls = (set && set.size) ? 'cg-sec' : 'cg-sec collapsed';
+          html += `<div class="${secCls}"><h4>${esc(fc.l)}</h4><div class="cg-chips" data-facet="${esc(fc.k)}">` +
+            ent.map(([k, c]) => `<span class="cg-chip${set && set.has(String(k)) ? ' active' : ''}" data-v="${esc(k)}">${esc(k)}<span class="cg-ct">${c}</span></span>`).join('') + `</div></div>`;
+        } else {
+          const rg = F.ranges[fc.k] || {};
+          const active = rg.min != null || rg.max != null;
+          const secCls = active ? 'cg-sec' : 'cg-sec collapsed';
+          const mn = rg.min != null ? ` value="${esc(rg.min)}"` : '';
+          const mx = rg.max != null ? ` value="${esc(rg.max)}"` : '';
+          html += `<div class="${secCls}"><h4>${esc(fc.l)}</h4><div class="cg-row2" data-facet="${esc(fc.k)}">` +
+            `<div class="cg-fld"><label>Min</label><input type="number" class="cg-min" placeholder="any"${mn}></div>` +
+            `<div class="cg-fld"><label>Max</label><input type="number" class="cg-max" placeholder="any"${mx}></div></div></div>`;
+        }
+      });
+      html += `<div class="cg-sec"><h4>Fields — show / hide</h4><div class="cg-ftogs">` +
+        fields.map(f => `<label class="cg-ftog"><input type="checkbox" data-fk="${esc(f.k)}" ${VIS[f.k] ? 'checked' : ''}><span>${esc(f.l)}</span>${f.col ? '<span class="cg-ct" title="List column">▤</span>' : ''}</label>`).join('') +
+        `</div><div class="cg-mini"><button data-all="1">All</button><button data-none="1">None</button><button data-def="1">Default</button></div></div>`;
+      rail.innerHTML = html;
+
+      // wire facets
+      rail.querySelectorAll('.cg-chips').forEach(box => box.addEventListener('click', e => {
+        const c = e.target.closest('.cg-chip'); if (!c) return;
+        const set = F.chips[box.dataset.facet]; const v = c.dataset.v;
+        set.has(v) ? set.delete(v) : set.add(v); c.classList.toggle('active'); render();
+      }));
+      rail.querySelectorAll('.cg-row2').forEach(box => {
+        const k = box.dataset.facet;
+        box.querySelector('.cg-min').addEventListener('input', e => { F.ranges[k].min = e.target.value ? +e.target.value : null; render(); });
+        box.querySelector('.cg-max').addEventListener('input', e => { F.ranges[k].max = e.target.value ? +e.target.value : null; render(); });
+      });
+      // field toggles
+      rail.querySelector('.cg-ftogs').addEventListener('change', e => { const cb = e.target.closest('input[data-fk]'); if (!cb) return; VIS[cb.dataset.fk] = cb.checked; saveVis(); render(); });
+      const mini = rail.querySelector('.cg-mini');
+      mini.addEventListener('click', e => {
+        const b = e.target.closest('button'); if (!b) return;
+        if (b.dataset.all) fields.forEach(f => VIS[f.k] = true);
+        else if (b.dataset.none) fields.forEach(f => VIS[f.k] = false);
+        else if (b.dataset.def) fields.forEach(f => VIS[f.k] = !!f.def);
+        saveVis(); buildRail(); render();
+      });
+      // collapsible headers
+      rail.querySelectorAll('.cg-sec > h4').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed')));
+    }
+
+    // ---- renderers ----
+    function cards(rows) {
+      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+      return rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<div class="cg-card ${rc}" data-ri="${i}">` +
+          `<div class="cg-name">${nm}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
+          (O.badgeHtml ? O.badgeHtml(r) : '') +
+          vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
+          `</div>`;
+      }).join('');
+    }
+    function tableView(rows) {
+      const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
+      const arrow = k => (SORT && SORT.k === k) ? (SORT.dir < 0 ? ' ▼' : ' ▲') : '';
+      const titleK = O.titleSortK || '';
+      const head = `<th class="cg-stick cg-sortth" data-k="${esc(titleK)}">${esc((O.titleCol || 'Name'))}${arrow(titleK)}</th>` +
+        cols.map(f => `<th class="cg-sortth" data-k="${esc(f.k)}">${esc(f.l)}${arrow(f.k)}</th>`).join('');
+      const body = rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<tr class="${rc}" data-ri="${i}"><td class="cg-stick"><span class="cg-la">${nm}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
+          cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
+      }).join('');
+      return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+    }
+    // compact single-row-per-record list: title on the left, every visible field
+    // flowing as inline key/value pairs on the right. Denser than cards, scannable,
+    // no fixed columns. Sorted via the topbar Sort select (all fields).
+    function listCompact(rows) {
+      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+      return `<div class="cg-rows">` + rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<div class="cg-lrow ${rc}" data-ri="${i}">` +
+          `<div class="cg-lmain"><span class="cg-la">${nm}</span>${t.sub ? `<span class="cg-lsub">${esc(t.sub)}</span>` : ''}${O.badgeHtml ? O.badgeHtml(r) : ''}</div>` +
+          `<div class="cg-lmeta">` +
+          vis.map(f => `<span class="cg-lm"><span class="cg-lmk">${esc(f.l)}</span>${fmt(fval(r, f), f)}</span>`).join('') +
+          `</div></div>`;
+      }).join('') + `</div>`;
+    }
+
+    // ---- active-filter summary bar (removable chips + clear all) ----
+    function activeFilterHTML() {
+      const parts = [];
+      facets.forEach(fc => {
+        if (fc.type === 'chip') {
+          const set = F.chips[fc.k];
+          if (set) set.forEach(v => parts.push(
+            `<span class="cg-af" data-fk="${esc(fc.k)}" data-aft="chip" data-v="${esc(v)}">${esc(fc.l)}: ${esc(v)} <b>✕</b></span>`));
+        } else {
+          const r = F.ranges[fc.k];
+          if (r && (r.min != null || r.max != null)) {
+            const txt = (r.min != null && r.max != null) ? esc(r.min) + '–' + esc(r.max)
+              : (r.min != null) ? '≥ ' + esc(r.min) : '≤ ' + esc(r.max);
+            parts.push(`<span class="cg-af" data-fk="${esc(fc.k)}" data-aft="range">${esc(fc.l)}: ${txt} <b>✕</b></span>`);
+          }
+        }
+      });
+      if (F.q) parts.push(`<span class="cg-af" data-aft="q">“${esc(F.q)}” <b>✕</b></span>`);
+      if (!parts.length) return '';
+      return `<span class="cg-af-lbl">Filters</span>` + parts.join('') +
+        `<button class="cg-af-clear" data-clearall="1">Clear all</button>` +
+        `<button class="cg-af-copy" data-copy="1" title="Copy a shareable link to this filtered view">🔗 Copy link</button>`;
+    }
+    function clearFilter(el) {
+      if (el.dataset.clearall != null) {
+        facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k].clear(); else F.ranges[fc.k] = { min: null, max: null }; });
+        F.q = ''; const q = $(O.mount + ' .cg-q'); if (q) q.value = '';
+      } else if (el.dataset.aft === 'q') {
+        F.q = ''; const q = $(O.mount + ' .cg-q'); if (q) q.value = '';
+      } else if (el.dataset.aft === 'chip') {
+        F.chips[el.dataset.fk].delete(el.dataset.v);
+      } else if (el.dataset.aft === 'range') {
+        F.ranges[el.dataset.fk] = { min: null, max: null };
+      }
+      buildRail(); render(); // buildRail re-renders the rail from F so it stays in sync
+    }
+
+    let stored = null; try { stored = localStorage.getItem(KEY + 'View'); } catch (e) {}
+    // v2 migration: the old engine's 'list' WAS the dense table. The new engine renames
+    // that spreadsheet to 'table' and adds a distinct compact 'list'. Re-point a *stored*
+    // legacy 'list' to 'table' exactly once so returning users keep the view they had.
+    try {
+      if (stored === 'list' && !localStorage.getItem(KEY + 'ViewV2')) { stored = 'table'; localStorage.setItem(KEY + 'View', 'table'); }
+      localStorage.setItem(KEY + 'ViewV2', '1');
+    } catch (e) {}
+    let VIEW = stored || O.view || 'grid';
+    const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
+    let lastShown = [];
+    function render() {
+      const rows = sortRows(data.filter(pass));
+      const shown = rows.length > CAP ? rows.slice(0, CAP) : rows;
+      lastShown = shown;
+      const g = $(O.mount + ' .cg-grid');
+      if (!rows.length) {
+        g.className = 'cg-grid';
+        g.innerHTML = '<div class="cg-empty"><div class="cg-empty-ic">⌕</div>' +
+          '<div class="cg-empty-t">No matches</div>' +
+          '<div class="cg-empty-s">No results match the current filters.</div>' +
+          '<button class="cg-af-clear cg-empty-btn" data-clearall="1">Clear all filters</button></div>';
+      }
+      else if (VIEW === 'table') { g.className = 'cg-grid cg-listhost'; g.innerHTML = tableView(shown); }
+      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = listCompact(shown); }
+      else { g.className = 'cg-grid'; g.innerHTML = cards(shown); }
+      const cnt = $(O.mount + ' .cg-count');
+      if (cnt) cnt.innerHTML = `<b>${rows.length.toLocaleString()}</b> of ${data.length.toLocaleString()}` + (rows.length > CAP ? ` <span style="opacity:.7">(first ${CAP.toLocaleString()} shown)</span>` : '');
+      const sel = $(O.mount + ' .cg-sort');
+      if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+      const ab = $(O.mount + ' .cg-active');
+      if (ab) ab.innerHTML = activeFilterHTML();
+      // render() rebuilds the whole table via innerHTML, so the previous
+      // col-resize enhancement is gone. Re-enhance deterministically instead of
+      // waiting on the async MutationObserver (which races the first interaction).
+      if (window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
+      syncURL();
+    }
+
+    // ---- topbar ----
+    function buildTop() {
+      const m = $(O.mount);
+      // ALL data points are sortable — not just the curated table columns.
+      const titleSK = O.titleSortK || '';
+      const sortable = fields.filter(f => !f.badge && f.k !== 'title' && f.k !== titleSK);
+      const titleOpts = titleSK
+        ? `<option value="${esc(titleSK)}:asc">${esc(O.titleCol || 'Name')} A→Z</option><option value="${esc(titleSK)}:desc">${esc(O.titleCol || 'Name')} Z→A</option>`
+        : '';
+      const sortOpts = `<option value="">Sort…</option>` + titleOpts + sortable.map(f =>
+        `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
+      m.insertAdjacentHTML('afterbegin',
+        `<div class="cg-top">` +
+        `<button type="button" class="cg-filt-btn" aria-label="Filters">☰ Filters</button>` +
+        `<input type="text" class="cg-q" placeholder="search…">` +
+        `<select class="cg-sort" title="Sort">${sortOpts}</select>` +
+        `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button><button data-view="table" class="${VIEW === 'table' ? 'active' : ''}">▤ Table</button></span>` +
+        `<label class="cg-dens">Density <input type="range" class="cg-cols" min="1" max="12" step="1" value="${O.cols || 3}"></label>` +
+        `<span class="cg-count"></span></div><div class="cg-active"></div><div class="cg-grid"></div>`);
+      const q = m.querySelector('.cg-q');
+      if (F.q) q.value = F.q; // reflect a search restored from the URL
+      q.addEventListener('input', () => { clearTimeout(q._t); q._t = setTimeout(() => { F.q = q.value.trim().toLowerCase(); render(); }, 160); });
+      m.querySelector('.cg-sort').addEventListener('change', e => {
+        const v = e.target.value;
+        if (!v) SORT = null;
+        else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
+        saveSort(); render();
+      });
+      m.querySelector('.cg-seg').addEventListener('click', e => { const b = e.target.closest('button[data-view]'); if (!b) return; VIEW = b.dataset.view; localStorage.setItem(KEY + 'View', VIEW); m.querySelectorAll('.cg-seg button').forEach(x => x.classList.toggle('active', x.dataset.view === VIEW)); render(); });
+      const cols = m.querySelector('.cg-cols');
+      const setCols = v => { m.style.setProperty('--cg-cols', v); m.style.setProperty('--cg-dens', v); localStorage.setItem(KEY + 'Cols', v); };
+      cols.addEventListener('input', e => setCols(e.target.value));
+      const sc = localStorage.getItem(KEY + 'Cols'); if (sc) { cols.value = sc; }
+      setCols(cols.value);
+      // mobile: "Filters" button opens the existing rail as an off-canvas drawer
+      const filtBtn = m.querySelector('.cg-filt-btn');
+      const railEl = $(O.rail);
+      if (filtBtn && railEl) {
+        const bd = document.createElement('div'); bd.className = 'cg-rail-backdrop'; document.body.appendChild(bd);
+        const openRail = () => { railEl.classList.add('open'); bd.classList.add('open'); };
+        const closeRail = () => { railEl.classList.remove('open'); bd.classList.remove('open'); };
+        filtBtn.addEventListener('click', () => railEl.classList.contains('open') ? closeRail() : openRail());
+        bd.addEventListener('click', closeRail);
+        document.addEventListener('keydown', e => { if (e.key === 'Escape') closeRail(); });
+      }
+      // th-click sorting + whole-row click (delegated; grid re-renders under us)
+      m.addEventListener('click', e => {
+        const cp = e.target.closest('.cg-af-copy');
+        if (cp) {
+          const done = () => { const t = cp.textContent; cp.textContent = '✓ Copied'; setTimeout(() => cp.textContent = t, 1200); };
+          if (navigator.clipboard) navigator.clipboard.writeText(location.href).then(done, done); else done();
+          return;
+        }
+        const af = e.target.closest('.cg-af, .cg-af-clear');
+        if (af) { clearFilter(af); return; }
+        // A click on the resize handle (or the tail of a resize drag) must NOT
+        // sort — otherwise grabbing the handle re-renders the whole table
+        // mid-gesture and the column never resizes. col-resize owns the handle.
+        if (e.target.closest('.cr-handle') || document.body.classList.contains('cr-dragging')) return;
+        const th = e.target.closest('.cg-sortth');
+        if (th && th.dataset.k) {
+          const k = th.dataset.k;
+          SORT = (SORT && SORT.k === k) ? { k, dir: -SORT.dir } : { k, dir: -1 };
+          saveSort(); render(); return;
+        }
+        if (O.onRowClick && !e.target.closest('a')) {
+          const tr = e.target.closest('tr[data-ri]');
+          if (tr) { O.onRowClick(lastShown[+tr.dataset.ri], tr); return; }
+          const card = e.target.closest('.cg-card[data-ri]');
+          if (card) O.onRowClick(lastShown[+card.dataset.ri], card);
+        }
+      });
+    }
+
+    // Export the CURRENTLY filtered + sorted rows (uncapped) as a CSV Blob — so a
+    // download reflects exactly what the user filtered to, not the whole dataset.
+    function exportCSV(opts) {
+      opts = opts || {};
+      const rows = sortRows(data.filter(pass));
+      const cols = fields.filter(f => !f.badge && f.k !== 'name'); // 'name' handled as the title column
+      const csvEsc = v => { if (v == null) return ''; v = String(v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
+      const header = ['Name'].concat(cols.map(f => f.l));
+      const lines = [header.map(csvEsc).join(',')];
+      rows.forEach(r => {
+        const t = O.title ? O.title(r) : { name: r.name || '' };
+        lines.push([csvEsc(t.name)].concat(cols.map(f => csvEsc(fval(r, f)))).join(','));
+      });
+      const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url; a.download = opts.filename || 'export.csv';
+      document.body.appendChild(a); a.click(); a.remove();
+      setTimeout(() => URL.revokeObjectURL(url), 1000);
+      return rows.length;
+    }
+
+    readFromURL(); buildTop(); buildRail(); render();
+    return {
+      render,
+      setData(d) { O.data.length = 0; d.forEach(x => O.data.push(x)); buildRail(); render(); },
+      exportCSV,
+      filteredCount() { return data.filter(pass).length; },
+    };
+  }
+
+  global.CrcpGrid = { mount: opts => new CrcpGrid(opts) };
+})(window);
diff --git a/scripts/li-archive-fetch.js b/scripts/li-archive-fetch.js
new file mode 100644
index 00000000..3bb7104e
--- /dev/null
+++ b/scripts/li-archive-fetch.js
@@ -0,0 +1,39 @@
+// Poll LinkedIn's download-my-data page via the logged-in openclaw Chrome. When the requested
+// archive is READY, download it and extract Connections.csv -> data/linkedin-connections.csv.
+// Idempotent: exits immediately if already wired. Prints "WIRED <n>" on success.
+const puppeteer = require(process.env.HOME + '/.npm-global/lib/node_modules/puppeteer');
+const fs = require('fs'), cp = require('child_process');
+const DEST = process.env.HOME + '/Projects/rentv/data/linkedin-connections.csv';
+const DL = '/tmp/li_dl';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+(async () => {
+  if (fs.existsSync(DEST) && fs.statSync(DEST).size > 1000) { console.log('ALREADY_WIRED'); return; }
+  let b;
+  try { b = await puppeteer.connect({ browserURL: 'http://127.0.0.1:18800', defaultViewport: null }); }
+  catch (e) { console.log('NO_BROWSER ' + e.message); return; }
+  const pages = await b.pages(); const pg = pages.find(p => true);
+  await pg.goto('https://www.linkedin.com/mypreferences/d/download-my-data', { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
+  await sleep(4500);
+  const ready = await pg.evaluate(() => {
+    const els = [...document.querySelectorAll('a,button')];
+    const dl = els.find(e => /^download( archive)?$/i.test((e.innerText || '').trim()) || /download your data|download archive/i.test(e.getAttribute('aria-label') || ''));
+    const pending = [...document.querySelectorAll('button')].some(b => /request pending/i.test(b.innerText || ''));
+    return { ready: !!dl, pending };
+  });
+  if (!ready.ready) { console.log(ready.pending ? 'PENDING' : 'NOT_READY'); await b.disconnect(); return; }
+  const client = await pg.target().createCDPSession();
+  try { fs.rmSync(DL, { recursive: true, force: true }); } catch (e) {} fs.mkdirSync(DL, { recursive: true });
+  await client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: DL });
+  await pg.evaluate(() => { const e = [...document.querySelectorAll('a,button')].find(x => /^download( archive)?$/i.test((x.innerText || '').trim())); e && e.click(); });
+  let f = null;
+  for (let i = 0; i < 90; i++) { await sleep(2000); const g = fs.readdirSync(DL).filter(n => /\.(zip|csv)$/i.test(n) && !/\.crdownload$/i.test(n)); if (g.length) { f = g[0]; break; } }
+  if (!f) { console.log('DOWNLOAD_TIMEOUT'); await b.disconnect(); return; }
+  let csv = null;
+  if (/\.zip$/i.test(f)) { cp.execSync(`cd ${DL} && unzip -o "${f}" >/dev/null 2>&1`); const c = fs.readdirSync(DL).find(n => /connections\.csv$/i.test(n)); if (c) csv = DL + '/' + c; }
+  else if (/connections\.csv$/i.test(f)) csv = DL + '/' + f;
+  if (!csv) { console.log('NO_CONNECTIONS_CSV (archive may be partial)'); await b.disconnect(); return; }
+  fs.copyFileSync(csv, DEST);
+  const n = fs.readFileSync(DEST, 'utf8').split(/\r?\n/).filter(Boolean).length;
+  console.log('WIRED ' + n);
+  await b.disconnect();
+})().catch(e => { console.error('ERR ' + e.message); process.exit(0); });
diff --git a/scripts/li-archive-poll.sh b/scripts/li-archive-poll.sh
new file mode 100644
index 00000000..480286a3
--- /dev/null
+++ b/scripts/li-archive-poll.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Loop tick: try to fetch+wire the LinkedIn archive. On success, email Steve + self-stop the launchd job.
+DEST="$HOME/Projects/rentv/data/linkedin-connections.csv"
+LOG="$HOME/Projects/rentv/logs/li-archive-poll.log"
+mkdir -p "$(dirname "$LOG")"
+ts(){ date "+%Y-%m-%d %H:%M:%S"; }
+
+OUT="$(/usr/bin/env node "$HOME/Projects/rentv/scripts/li-archive-fetch.js" 2>&1)"
+echo "$(ts) $OUT" >> "$LOG"
+
+# already-wired or just-wired?
+if [ -s "$DEST" ] && [ "$(wc -c < "$DEST")" -gt 1000 ]; then
+  # count connections + live CRM matches
+  CONNS=$(grep -ac "," "$DEST" 2>/dev/null)
+  AUTH=$(printf 'admin:DW2024!' | base64)
+  MATCHES=$(curl -s -H "Authorization: Basic $AUTH" http://127.0.0.1:9704/api/crm | python3 -c "import json,sys;print(sum(1 for c in json.load(sys.stdin)['contacts'] if c.get('li_follow')))" 2>/dev/null)
+  BODY="Your LinkedIn data archive has been downloaded and wired into the RENTV CRM.
+
+File: data/linkedin-connections.csv (~${CONNS} connections)
+Green-highlight matches now live in the CRM: ${MATCHES} contacts you already know.
+
+View: http://localhost:9704/crm (Directory/Lists — green rows/badges; also the Table + Cross-Ref Mind Map).
+No restart needed — the CRM reads the connections file per request.
+
+— automated LinkedIn-archive watcher"
+
+  if [ -f "$HOME/.claude/skills/_shared/george-send.sh" ]; then
+    . "$HOME/.claude/skills/_shared/george-send.sh"
+    george_send steve-office "steve@designerwallcoverings.com" "✅ LinkedIn archive downloaded → CRM green-highlight upgraded (${MATCHES} matches)" "$BODY" >> "$LOG" 2>&1
+    echo "$(ts) EMAILED + stopping loop" >> "$LOG"
+  fi
+  # self-stop: unload + disable so it never runs again
+  launchctl bootout "gui/$(id -u)/com.steve.li-archive-fetch" 2>/dev/null
+  exit 0
+fi
+exit 0
diff --git a/server.js b/server.js
index 23cedf36..9db92630 100644
--- a/server.js
+++ b/server.js
@@ -33,6 +33,28 @@ const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(
 // asArr() forces an array at every consumption site so a malformed feed degrades to empty, never a 500.
 const asArr = (x) => (Array.isArray(x) ? x : []);
 
+// ── Injection-safe generic row sorter (TK-10526). Every list API accepts
+//    ?sort=<col>&order=asc|desc. `whitelist` is the STRICT set of sortable keys
+//    for that route — a ?sort= not on the list is ignored (rows returned in
+//    their current/default order), so there is no way to reach an arbitrary
+//    field or an eval path. Numbers sort numerically, everything else by
+//    locale string; null/blank always sinks to the bottom regardless of dir. ──
+function sortRowsBy(rows, sortKey, order, whitelist) {
+  if (!Array.isArray(rows) || !sortKey) return rows;
+  const key = String(sortKey);
+  if (!whitelist.includes(key)) return rows; // strict allow-list; unknown col = no-op
+  const dir = String(order).toLowerCase() === 'asc' ? 1 : -1;
+  const num = (v) => (v == null || v === '' ? null : (typeof v === 'number' ? v : (/^-?\d+(\.\d+)?$/.test(String(v).replace(/[$,%\s]/g, '')) ? +String(v).replace(/[$,%\s]/g, '') : null)));
+  return [...rows].sort((a, b) => {
+    const va = a ? a[key] : null, vb = b ? b[key] : null;
+    const ea = va == null || va === '', eb = vb == null || vb === '';
+    if (ea && eb) return 0; if (ea) return 1; if (eb) return -1; // blanks last, both dirs
+    const na = num(va), nb = num(vb);
+    if (na != null && nb != null) return (na - nb) * dir;
+    return String(va).localeCompare(String(vb)) * dir;
+  });
+}
+
 // ── Two-tier auth: USER (front page only) + ADMIN (front page + all internal data). ──
 // Role is resolved from WHICH cred matched, then req.role drives every gate below.
 // In prod the single nginx basic-auth wall must contain EVERY login here (both tiers);
@@ -331,6 +353,13 @@ app.get('/api/deals', (req, r) => {
     const needle = String(q).toLowerCase();
     deals = deals.filter((d) => `${d.title} ${d.city} ${d.state} ${d.summary}`.toLowerCase().includes(needle));
   }
+  // ?sort=<col>&order=asc|desc — strict whitelist, injection-safe (TK-10526).
+  if (req.query.sort) {
+    deals = sortRowsBy(deals, req.query.sort, req.query.order, [
+      'title', 'txn_type', 'property_type', 'city', 'state', 'amount', 'size_label',
+      'units', 'sqft', 'address', 'occupancy_pct', 'year_built', 'acquirer',
+    ]);
+  }
   const limit = Math.min(parseInt(req.query.limit, 10) || deals.length, 200);
   r.json({ source: data.source, fetched_at: data.fetched_at, count: deals.length, deals: deals.slice(0, limit) });
 });
@@ -397,7 +426,14 @@ app.get('/api/articles', adminOnly, (req, r) => {
   if (type) rows = rows.filter((a) => (a.property_type || '').toLowerCase() === String(type).toLowerCase());
   if (year) rows = rows.filter((a) => String(a.date || '').startsWith(String(year)));
   if (q) { const n = String(q).toLowerCase(); rows = rows.filter((a) => `${a.title} ${a.city} ${a.state} ${a.summary}`.toLowerCase().includes(n)); }
-  rows.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+  // Default order = newest first; ?sort=<col>&order= overrides (whitelist, TK-10526).
+  if (req.query.sort) {
+    rows = sortRowsBy(rows, req.query.sort, req.query.order, [
+      'title', 'date', 'city', 'state', 'txn_type', 'property_type', 'summary',
+    ]);
+  } else {
+    rows = rows.slice().sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+  }
   const total = rows.length;
   const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
   const limit = Math.min(parseInt(req.query.limit, 10) || 200, 1000);
@@ -1639,21 +1675,34 @@ app.get('/post/:id', (_q, r) => sendPage(r, path.join(PUB, 'post.html')));
 //    exposing the internal service; gated by the same auth middleware above.
 const USRE = process.env.USRE_URL || 'http://127.0.0.1:9913';
 const USRE_AUTH = 'Basic ' + Buffer.from(process.env.USRE_AUTH || 'admin:DW2024!').toString('base64');
-async function usreProxy(upstream, req, res) {
+// The upstream usre registry has a FIXED ORDER BY (b.name) and is owned by another
+// agent (claude-re), so ?sort/?order can't be passed through. Instead we sort the
+// fetched page server-side here (TK-10526), against a strict per-route whitelist —
+// injection-safe. `sortWL` = the sortable columns for that upstream shape; when the
+// caller omits ?sort the upstream's default order is preserved untouched.
+async function usreProxy(upstream, req, res, sortWL) {
   try {
     const qs = new URLSearchParams(req.query);
     if (!qs.get('state')) qs.set('state', 'CA');
     if (!qs.get('limit')) qs.set('limit', '500');
+    qs.delete('sort'); qs.delete('order'); // never forward — we sort locally
     const r = await fetch(`${USRE}${upstream}?${qs}`, { headers: { Authorization: USRE_AUTH }, signal: AbortSignal.timeout(12000) });
     if (!r.ok) return res.status(502).json({ rows: [], total: 0, error: 'upstream ' + r.status });
+    const body = await r.json();
+    if (req.query.sort && Array.isArray(body && body.rows) && Array.isArray(sortWL)) {
+      body.rows = sortRowsBy(body.rows, req.query.sort, req.query.order, sortWL);
+    }
     res.set('Cache-Control', 'private, max-age=60');
-    res.json(await r.json());
+    res.json(body);
   } catch (e) { res.status(502).json({ rows: [], total: 0, error: 'registry unavailable' }); }
 }
-app.get('/api/brokers', adminOnly, (req, res) => usreProxy('/api/brokers', req, res));
-app.get('/api/firms', adminOnly, (req, res) => usreProxy('/api/firms', req, res));
+const BROKER_SORT_WL = ['name', 'license_no', 'license_state', 'license_type', 'license_status', 'city', 'state_code', 'source', 'firm_name', 'broker_phone', 'broker_email', 'broker_website', 'firm_website', 'firm_phone', 'firm_email'];
+const FIRM_SORT_WL = ['name', 'city', 'state', 'agent_count', 'website', 'phone', 'email', 'source'];
+const SUBLEASE_SORT_WL = ['title', 'city', 'state', 'submarket', 'sqft', 'size', 'price', 'rate', 'available', 'firm', 'broker', 'property_type', 'term'];
+app.get('/api/brokers', adminOnly, (req, res) => usreProxy('/api/brokers', req, res, BROKER_SORT_WL));
+app.get('/api/firms', adminOnly, (req, res) => usreProxy('/api/firms', req, res, FIRM_SORT_WL));
 app.get('/api/firm/:id', adminOnly, (req, res) => usreProxy('/api/firm/' + encodeURIComponent(req.params.id), req, res)); // firm detail + broker roster (drill firm→people)
-app.get('/api/sublease', adminOnly, (req, res) => usreProxy('/api/sublease', req, res));
+app.get('/api/sublease', adminOnly, (req, res) => usreProxy('/api/sublease', req, res, SUBLEASE_SORT_WL));
 
 // ── Summit Leads (growth idea #1 + #3): turn the CA registry (382k brokers / 34k firms)
 //    into ranked, exportable summit INVITE lists + SPONSOR-TARGET lists. metro fans out to

← edec58bd auto-data-snapshot: 2026-08-13T08:21:55 (7 data files) — dat  ·  back to Rentv  ·  directory.html: add Sort select + Grid/List/Table views + cl 0169120d →