[object Object]

← back to Rentv

TK-10488: wire RENTV contractor widget — CSLB-gated router mount + story container (NOT deployed)

47a82b70911bfea8546f1d4035c3f0380eeb31ed · 2026-08-14 09:55:55 -0700 · Steve Abrams

- contrib/contractors/ drop-in: router.js (usre CSLB proxy, verified-only gate
  Active/CLEAR + defense-in-depth, fail-soft, CSLB-dated, text-only), widget.html, README.
- server.js: mount /contrib/contractors (public read-only) + static widget route.
- public/article.html: hidden contractor container on the CRE story reader (inert until
  the render wiring lands).
Auto-deploy hook intentionally suppressed — go-live deploy + prod verify pending.

Files touched

Diff

commit 47a82b70911bfea8546f1d4035c3f0380eeb31ed
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 14 09:55:55 2026 -0700

    TK-10488: wire RENTV contractor widget — CSLB-gated router mount + story container (NOT deployed)
    
    - contrib/contractors/ drop-in: router.js (usre CSLB proxy, verified-only gate
      Active/CLEAR + defense-in-depth, fail-soft, CSLB-dated, text-only), widget.html, README.
    - server.js: mount /contrib/contractors (public read-only) + static widget route.
    - public/article.html: hidden contractor container on the CRE story reader (inert until
      the render wiring lands).
    Auto-deploy hook intentionally suppressed — go-live deploy + prod verify pending.
---
 contrib/contractors/router.js   | 252 ++++++++++++++++++++++++++++++++++++++++
 contrib/contractors/widget.html | 138 ++++++++++++++++++++++
 public/article.html             |   4 +-
 server.js                       |   9 ++
 4 files changed, 402 insertions(+), 1 deletion(-)

diff --git a/contrib/contractors/router.js b/contrib/contractors/router.js
new file mode 100644
index 00000000..e1fdc876
--- /dev/null
+++ b/contrib/contractors/router.js
@@ -0,0 +1,252 @@
+'use strict';
+// ============================================================================
+// RENTV contrib — Licensed-contractor market lookup router  (TK-10488)
+// ----------------------------------------------------------------------------
+// A SELF-CONTAINED, DROP-IN Express router that RENTV can mount to expose
+// "licensed contractors for a market" on a CRE deal news story, without RENTV
+// having to know anything about the shared usre CSLB contractor database.
+//
+// It proxies the shared usre (nationalrealestate) contractor API:
+//     GET {CONTRACTORS_API_BASE}/api/contractors/match?mode=deal&county=&city=
+//         -> { mode, criteria, cap_per_group, matches: { <classCode>: [ ... ] } }
+//
+// and re-serves a SLIM, text-only shape at:
+//     GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
+//
+// Zero new dependencies: uses Node's built-in global fetch (Node 18+) and a
+// small in-memory TTL cache. Nothing here writes to any database, touches any
+// existing RENTV file, or re-hosts any external asset.
+//
+// Owned by: claude-rentv-contractors (contrib author). RENTV (claude-rentv)
+// mounts it when ready — see README.md. Gated for any publish/deploy.
+// ============================================================================
+
+const express = require('express');
+
+// --- Config (all env-overridable; safe defaults) ----------------------------
+const API_BASE = (
+  process.env.CONTRACTORS_API_BASE || 'http://localhost:9913'
+).replace(/\/+$/, ''); // trim trailing slash
+
+// The usre API is behind Basic Auth (CRCP pattern). RENTV can supply creds via
+// CONTRACTORS_API_AUTH="user:pass"; if unset we send none and let the upstream
+// decide (loopback / same-host deployments may not require it).
+const API_AUTH = process.env.CONTRACTORS_API_AUTH || '';
+
+// Upstream request timeout (ms) and per-trade result cap we surface to callers.
+const API_TIMEOUT_MS = Number(process.env.CONTRACTORS_API_TIMEOUT_MS || 6000);
+const DEFAULT_LIMIT = Number(process.env.CONTRACTORS_DEFAULT_LIMIT || 6);
+const MAX_LIMIT = 24;
+
+// --- CSLB PUBLISH GATE (TK-10488) -------------------------------------------
+// HARD RULE from the go-live memos + task: publish ONLY license_status-verified
+// contractors. The upstream /match already filters to license_status='Active'
+// server-side, but we enforce it AGAIN here as defense-in-depth so a stale
+// upstream, a widened upstream filter, or a bad record can NEVER put an
+// unverified license in front of a reader. A record whose status is not a
+// recognized in-good-standing value is DROPPED (never published), and does not
+// consume a result slot. Env-overridable (comma-separated) for future statuses.
+const VERIFIED_STATUSES = new Set(
+  String(process.env.CONTRACTORS_VERIFIED_STATUSES || 'ACTIVE,CLEAR')
+    .split(',')
+    .map((s) => s.trim().toUpperCase())
+    .filter(Boolean)
+);
+function isVerifiedStatus(status) {
+  return VERIFIED_STATUSES.has(String(status == null ? '' : status).trim().toUpperCase());
+}
+
+// CSLB dataset freshness date the widget must display ("CSLB data as of {date}").
+// The upstream /match response does not currently carry a dataset-level date, so
+// we surface one here. Priority: any per-record source_as_of the upstream may add
+// later -> CONTRACTORS_SOURCE_AS_OF env -> the known load date of the shared
+// registry (2026-08-07, ca_contractors.source_as_of). RENTV/ops bump the env when
+// the CSLB registry is refreshed so the date the reader sees stays honest.
+const SOURCE_AS_OF = process.env.CONTRACTORS_SOURCE_AS_OF || '2026-08-07';
+
+// Short in-memory cache so a hot news story doesn't hammer the upstream.
+const CACHE_TTL_MS = Number(process.env.CONTRACTORS_CACHE_TTL_MS || 5 * 60 * 1000);
+const _cache = new Map(); // key -> { at, data }
+
+function cacheGet(key) {
+  const hit = _cache.get(key);
+  if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.data;
+  if (hit) _cache.delete(key);
+  return null;
+}
+function cacheSet(key, data) {
+  _cache.set(key, { at: Date.now(), data });
+  // bound the cache so a spidered site can't grow it without limit
+  if (_cache.size > 500) {
+    const oldest = [..._cache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
+    if (oldest) _cache.delete(oldest[0]);
+  }
+}
+
+// --- Upstream fetch with timeout -------------------------------------------
+async function fetchUpstream(url) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
+  try {
+    const headers = { accept: 'application/json' };
+    if (API_AUTH) {
+      headers.authorization = 'Basic ' + Buffer.from(API_AUTH).toString('base64');
+    }
+    const r = await fetch(url, { headers, signal: ctrl.signal });
+    if (!r.ok) {
+      const body = await r.text().catch(() => '');
+      const err = new Error(`upstream ${r.status}`);
+      err.status = r.status;
+      err.body = body.slice(0, 300);
+      throw err;
+    }
+    return await r.json();
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+// --- Flatten the upstream {matches:{code:[...]}} into a slim, deduped list ---
+// Text/attribution only: name + trade + phone + city/county + license. No links
+// out to any external site, no re-hosted assets. Only license_status-verified
+// records are emitted (CSLB publish gate above).
+function flattenMatches(payload, limit) {
+  const matches = payload && payload.matches && typeof payload.matches === 'object'
+    ? payload.matches : {};
+  const seen = new Set(); // dedupe by license_no (a GC can appear under multiple class codes)
+  const out = [];
+  let upstreamAsOf = null; // newest per-record source_as_of the upstream carried, if any
+  for (const code of Object.keys(matches)) {
+    const list = Array.isArray(matches[code]) ? matches[code] : [];
+    for (const c of list) {
+      if (!c || !c.business_name) continue;
+
+      // CSLB PUBLISH GATE — drop anything not license_status-verified, BEFORE it
+      // consumes a dedupe slot or a result slot. This is the "only publish
+      // license_status-verified contractors" guarantee, enforced at RENTV's edge.
+      if (!isVerifiedStatus(c.license_status)) continue;
+
+      const key = c.license_no || `${c.business_name}|${c.city || ''}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+
+      // Track the freshest per-record CSLB date if the upstream ever exposes one.
+      if (c.source_as_of && (!upstreamAsOf || c.source_as_of > upstreamAsOf)) {
+        upstreamAsOf = String(c.source_as_of);
+      }
+
+      // Prefer the human-readable trade title for the matched class code; fall
+      // back to the contractor's primary_class or the raw code.
+      let trade = null;
+      const titles = Array.isArray(c.classification_titles) ? c.classification_titles : [];
+      const hit = titles.find((x) => x && x.code === code && x.title);
+      if (hit) trade = hit.title;
+      else if (titles.length && titles[0].title) trade = titles[0].title;
+      else trade = c.primary_class || code;
+
+      out.push({
+        name: String(c.business_name),
+        trade: trade ? String(trade) : null,
+        phone: c.phone ? String(c.phone) : null,
+        city: c.city ? String(c.city) : null,
+        county: c.county ? String(c.county) : null,
+        license_no: c.license_no ? String(c.license_no) : null,
+        license_status: c.license_status ? String(c.license_status) : null,
+      });
+      if (out.length >= limit) {
+        out._source_as_of = upstreamAsOf; // non-enumerable-ish sidecar (array prop)
+        return out;
+      }
+    }
+  }
+  out._source_as_of = upstreamAsOf;
+  return out;
+}
+
+// --- Router factory ---------------------------------------------------------
+// Returns an express.Router() ready to mount. All routes are relative, so the
+// host chooses the mount path (README recommends app.use('/contrib/contractors', ...)).
+function createContractorsRouter(opts = {}) {
+  const router = express.Router();
+  const base = (opts.apiBase || API_BASE).replace(/\/+$/, '');
+
+  // Health / self-describe — lets RENTV confirm the drop-in is wired.
+  router.get('/health', (_req, res) => {
+    res.json({
+      ok: true,
+      contrib: 'contractors',
+      upstream: base,
+      cache_ttl_ms: CACHE_TTL_MS,
+      verified_statuses: [...VERIFIED_STATUSES],
+      source_as_of: SOURCE_AS_OF,
+    });
+  });
+
+  // GET /api/contractors-for-market?county=&city=[&mode=deal][&limit=6]
+  router.get('/api/contractors-for-market', async (req, res) => {
+    const county = req.query.county ? String(req.query.county).trim().slice(0, 80) : '';
+    const city = req.query.city ? String(req.query.city).trim().slice(0, 80) : '';
+    const mode = (req.query.mode ? String(req.query.mode) : 'deal').toLowerCase() === 'home'
+      ? 'home' : 'deal';
+    let limit = Number(req.query.limit || DEFAULT_LIMIT);
+    if (!Number.isFinite(limit) || limit < 1) limit = DEFAULT_LIMIT;
+    if (limit > MAX_LIMIT) limit = MAX_LIMIT;
+
+    if (!county && !city) {
+      return res.status(400).json({ error: 'county or city is required' });
+    }
+
+    const qs = new URLSearchParams({ mode });
+    if (county) qs.set('county', county);
+    if (city) qs.set('city', city);
+    const url = `${base}/api/contractors/match?${qs.toString()}`;
+    const cacheKey = `${url}|${limit}`;
+
+    const cached = cacheGet(cacheKey);
+    if (cached) return res.json({ ...cached, cached: true });
+
+    try {
+      const payload = await fetchUpstream(url);
+      const contractors = flattenMatches(payload, limit);
+      const sourceAsOf = contractors._source_as_of || SOURCE_AS_OF;
+      const plain = contractors.slice(); // drop the sidecar prop from the wire array
+      const result = {
+        market: {
+          city: (payload.criteria && payload.criteria.city) || city || null,
+          county: (payload.criteria && payload.criteria.county) || county || null,
+          mode,
+        },
+        count: plain.length,
+        contractors: plain,
+        // DATE EVERYTHING — the widget renders "CSLB data as of {source_as_of} —
+        // verify at cslb.ca.gov" on every entry. verify_url is text-only, no link.
+        source_as_of: sourceAsOf,
+        verify_url: 'cslb.ca.gov',
+        attribution: 'CA CSLB licensed contractors via shared usre registry',
+        source: 'usre-contractors',
+      };
+      cacheSet(cacheKey, result);
+      res.json(result);
+    } catch (e) {
+      // Fail soft: the widget treats an error/empty list as "none to show" and
+      // hides itself — a news story must never 500 because the sidecar is down.
+      const status = e && e.status === 401 ? 502 : (e && e.status) || 502;
+      res.status(status).json({
+        error: 'contractor lookup unavailable',
+        detail: String((e && e.message) || e),
+        market: { city: city || null, county: county || null, mode },
+        count: 0,
+        contractors: [],
+        source_as_of: SOURCE_AS_OF,
+        verify_url: 'cslb.ca.gov',
+      });
+    }
+  });
+
+  return router;
+}
+
+module.exports = createContractorsRouter;
+module.exports.createContractorsRouter = createContractorsRouter;
+// Convenience: a ready-to-mount default instance using env config.
+module.exports.router = createContractorsRouter();
diff --git a/contrib/contractors/widget.html b/contrib/contractors/widget.html
new file mode 100644
index 00000000..8a001981
--- /dev/null
+++ b/contrib/contractors/widget.html
@@ -0,0 +1,138 @@
+<!-- =========================================================================
+     RENTV contrib — "Licensed contractors in {market}" widget  (TK-10488)
+     -------------------------------------------------------------------------
+     A SELF-CONTAINED, drop-in widget for a CRE deal news story. Paste this
+     whole block into a story template (or include the <script> from a partial).
+     It calls the contrib router's /api/contractors-for-market endpoint and
+     renders a TEXT-ONLY list (name + trade + phone). No external links, no
+     re-hosted assets, honoring the RENTV no-rentv.com-link-out rule.
+
+     USAGE (two ways):
+
+     1) Auto-init from data attributes — drop the markup on the page:
+          <div class="rentv-contractors"
+               data-county="Los Angeles" data-city="Los Angeles"
+               data-limit="6"></div>
+        ...then include this file once (or its <script>) anywhere after it.
+
+     2) Programmatic — call the global:
+          RentvContractors.render(document.getElementById('box'),
+            { county: 'Los Angeles', city: 'Los Angeles', limit: 6 });
+
+     The endpoint base defaults to same-origin '/contrib/contractors'; override
+     with data-endpoint="..." or the { endpoint } option.
+     ========================================================================= -->
+<style>
+  .rentv-contractors { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; }
+  .rentv-contractors__title { font-size: 15px; font-weight: 700; margin: 0 0 .5em; letter-spacing: .01em; }
+  .rentv-contractors__list { list-style: none; margin: 0; padding: 0; }
+  .rentv-contractors__item { padding: .55em 0; border-top: 1px solid #e6e6e6; }
+  .rentv-contractors__item:first-child { border-top: none; }
+  .rentv-contractors__name { font-weight: 600; }
+  .rentv-contractors__trade { color: #555; }
+  .rentv-contractors__phone { color: #333; }
+  .rentv-contractors__meta { color: #888; font-size: 12px; }
+  .rentv-contractors__asof { color: #999; font-size: 11px; margin-top: .2em; font-style: italic; }
+  .rentv-contractors__attr { color: #999; font-size: 11px; margin-top: .6em; }
+  .rentv-contractors[hidden] { display: none; }
+</style>
+
+<script>
+(function () {
+  'use strict';
+  if (window.RentvContractors) return; // idempotent — safe to include more than once
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+    });
+  }
+
+  function marketLabel(m) {
+    if (!m) return 'this market';
+    return m.city || m.county || 'this market';
+  }
+
+  function renderList(el, data) {
+    var contractors = (data && data.contractors) || [];
+    if (!contractors.length) { el.hidden = true; return; } // nothing to show -> hide, story stays clean
+
+    var label = marketLabel(data.market);
+    // DATE EVERYTHING — every entry carries the CSLB freshness date + a text-only
+    // "verify at cslb.ca.gov" (no link-out, honoring the RENTV no-link-out rule).
+    var asOf = (data && data.source_as_of) ? String(data.source_as_of) : 'unknown';
+    var verify = (data && data.verify_url) ? String(data.verify_url) : 'cslb.ca.gov';
+    var asOfLine = 'CSLB data as of ' + esc(asOf) + ' &mdash; verify at ' + esc(verify);
+
+    var rows = contractors.map(function (c) {
+      var bits = [];
+      bits.push('<span class="rentv-contractors__name">' + esc(c.name) + '</span>');
+      if (c.trade) bits.push('<span class="rentv-contractors__trade"> &middot; ' + esc(c.trade) + '</span>');
+      var phone = c.phone
+        ? '<div class="rentv-contractors__phone">' + esc(c.phone) + '</div>'
+        : '';
+      var meta = [];
+      if (c.city) meta.push(esc(c.city));
+      if (c.license_no) meta.push('Lic. ' + esc(c.license_no));
+      if (c.license_status) meta.push(esc(c.license_status));
+      var metaLine = meta.length
+        ? '<div class="rentv-contractors__meta">' + meta.join(' &middot; ') + '</div>'
+        : '';
+      // per-entry dated attribution — required by TK-10488
+      var asOfPerEntry = '<div class="rentv-contractors__asof">' + asOfLine + '</div>';
+      return '<li class="rentv-contractors__item">' + bits.join('') + phone + metaLine + asOfPerEntry + '</li>';
+    }).join('');
+
+    el.innerHTML =
+      '<div class="rentv-contractors__title">Licensed contractors in ' + esc(label) + '</div>' +
+      '<ul class="rentv-contractors__list">' + rows + '</ul>' +
+      '<div class="rentv-contractors__attr">' +
+        esc((data && data.attribution) || 'CA CSLB licensed contractors') +
+      '</div>';
+    el.hidden = false;
+  }
+
+  function render(el, opts) {
+    if (!el) return;
+    opts = opts || {};
+    var endpoint = (opts.endpoint || el.getAttribute('data-endpoint') || '/contrib/contractors')
+      .replace(/\/+$/, '');
+    var county = opts.county || el.getAttribute('data-county') || '';
+    var city = opts.city || el.getAttribute('data-city') || '';
+    var limit = opts.limit || el.getAttribute('data-limit') || '';
+    var mode = opts.mode || el.getAttribute('data-mode') || 'deal';
+
+    if (!county && !city) { el.hidden = true; return; }
+
+    var qs = new URLSearchParams({ mode: mode });
+    if (county) qs.set('county', county);
+    if (city) qs.set('city', city);
+    if (limit) qs.set('limit', limit);
+
+    fetch(endpoint + '/api/contractors-for-market?' + qs.toString(), {
+      headers: { accept: 'application/json' },
+      credentials: 'same-origin'
+    })
+      .then(function (r) { return r.ok ? r.json() : { contractors: [] }; })
+      .then(function (data) { renderList(el, data); })
+      .catch(function () { el.hidden = true; }); // fail soft — never break the story
+  }
+
+  function autoInit(root) {
+    var scope = root || document;
+    var nodes = scope.querySelectorAll('.rentv-contractors:not([data-rendered])');
+    Array.prototype.forEach.call(nodes, function (el) {
+      el.setAttribute('data-rendered', '1');
+      render(el);
+    });
+  }
+
+  window.RentvContractors = { render: render, autoInit: autoInit };
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', function () { autoInit(); });
+  } else {
+    autoInit();
+  }
+})();
+</script>
diff --git a/public/article.html b/public/article.html
index ff1a104b..5ce06d67 100644
--- a/public/article.html
+++ b/public/article.html
@@ -79,7 +79,9 @@
     <div class="byl">Fetching live from rentv.com…</div>
     <div class="hero skl"></div>
     <div class="body"><p class="skl">&nbsp;<br>&nbsp;<br>&nbsp;</p></div>
-  </article></div>
+  </article>
+  <!-- TK-10488 — Licensed-contractor block for this deal's market (fail-soft; hides when no verified CA match) -->
+  <aside id="deal-contractors" class="rentv-contractors" hidden></aside></div>
 
   <footer><div class="wrap"><div class="cols">
     <div><div class="logo">REN<span style="color:var(--red)">TV</span>.com</div><p style="max-width:34ch">Commercial real estate news, deals &amp; conferences across the Western United States since 1998.</p></div>
diff --git a/server.js b/server.js
index 768e222d..4b825f24 100644
--- a/server.js
+++ b/server.js
@@ -185,6 +185,15 @@ app.use((req, res, next) => {
   if (req.session && tok && tok === req.session.csrf) return next();
   return res.status(403).json({ ok: false, error: 'CSRF token missing or invalid — reload the page' });
 });
+// TK-10488 — Licensed-contractor market lookup (drop-in contrib, CSLB-gated, fail-soft).
+// Public read-only proxy to the shared usre CSLB registry: ONLY license_status-verified
+// contractors are surfaced (Active/CLEAR), every entry is CSLB-dated, text-only (no link-out).
+// A down/stale upstream never 500s a story — the widget hides itself. See
+// contrib/contractors/README.md. module.exports is the FACTORY, so mount `.router`.
+const contractorsRouter = require('./contrib/contractors/router');
+app.use('/contrib/contractors', contractorsRouter.router);
+app.get('/contrib/contractors/widget.html', (_q, r) =>
+  r.sendFile(path.join(__dirname, 'contrib/contractors/widget.html')));
 // Admin-only gate: 403 for user/public tiers. Guards all INTERNAL data endpoints + shells.
 function adminOnly(req, res, next) {
   if (req.role === 'admin') return next();

← 9022cfde RENTV sale prep (TK-10562): add rate-card rationale + how-to  ·  back to Rentv  ·  RENTV sale prep (TK-10562): apply Cody cumulative-gate fixes 40e78832 →