← 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
A contrib/contractors/router.jsA contrib/contractors/widget.htmlM public/article.htmlM server.js
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) + ' — 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"> · ' + 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(' · ') + '</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"> <br> <br> </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 & 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 →