[object Object]

← back to Dw Yolo Loop

Guard the vendor-microsite generator against showroom-only lines (TK-11200)

5b0f86098423f10b5c23bf74bf30071860d2b176 · 2026-09-10 07:50:02 -0700 · Steve Abrams

The *.designerwallcoverings.com vendor microsites are minted by this artmura-site
template, not by dw-domain-fleet — so the isShowroomVendor guard added there never
covered them. Showroom-only lines (Phillip Jeffries) must stay "addressable but not
discoverable", and a public browsable grid on its own subdomain is a discovery surface.

New showroom.js (self-contained so it rsyncs with a BUNDLE=1 deploy) + a local copy of
the canonical showroom-vendors.json, wired into all three stages:

- build-line.js  refuses to snapshot a showroom vendor; drops showroom-TAGGED products
                 from any snapshot; and refuses to write a 0-product snapshot (a bad
                 token silently blanked a live microsite's grid).
- deploy-vendor.sh refuses by slug, domain label, or the snapshot's own vendor field.
- server.js      refuses to serve a showroom-vendor config; hides showroom-TAGGED
                 products from the grid at catalog load.

Matching normalization collapses the "Philip"/"Phillip" spelling split and separator-less
slugs, so a one-letter typo or a renamed slug can't walk past the guard.
Escape hatch: ALLOW_SHOWROOM=1 (always logs loudly).

Verified locally: showroom vendor/slug/domain refused at all three stages; carnegie line
still boots and serves 5934 products (/api/config, /api/products, /api/facets all 200);
tag filter hides exactly the tagged rows. Nothing deployed — prod stays gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cwgg8nQa46x1QZewDe7bGn

Files touched

Diff

commit 5b0f86098423f10b5c23bf74bf30071860d2b176
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 07:50:02 2026 -0700

    Guard the vendor-microsite generator against showroom-only lines (TK-11200)
    
    The *.designerwallcoverings.com vendor microsites are minted by this artmura-site
    template, not by dw-domain-fleet — so the isShowroomVendor guard added there never
    covered them. Showroom-only lines (Phillip Jeffries) must stay "addressable but not
    discoverable", and a public browsable grid on its own subdomain is a discovery surface.
    
    New showroom.js (self-contained so it rsyncs with a BUNDLE=1 deploy) + a local copy of
    the canonical showroom-vendors.json, wired into all three stages:
    
    - build-line.js  refuses to snapshot a showroom vendor; drops showroom-TAGGED products
                     from any snapshot; and refuses to write a 0-product snapshot (a bad
                     token silently blanked a live microsite's grid).
    - deploy-vendor.sh refuses by slug, domain label, or the snapshot's own vendor field.
    - server.js      refuses to serve a showroom-vendor config; hides showroom-TAGGED
                     products from the grid at catalog load.
    
    Matching normalization collapses the "Philip"/"Phillip" spelling split and separator-less
    slugs, so a one-letter typo or a renamed slug can't walk past the guard.
    Escape hatch: ALLOW_SHOWROOM=1 (always logs loudly).
    
    Verified locally: showroom vendor/slug/domain refused at all three stages; carnegie line
    still boots and serves 5934 products (/api/config, /api/products, /api/facets all 200);
    tag filter hides exactly the tagged rows. Nothing deployed — prod stays gated.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Cwgg8nQa46x1QZewDe7bGn
---
 artmura-site/build-line.js                |  17 ++++-
 artmura-site/config/showroom-vendors.json |   1 +
 artmura-site/deploy-vendor.sh             |  12 ++++
 artmura-site/server.js                    |  18 ++++-
 artmura-site/showroom.js                  | 106 ++++++++++++++++++++++++++++++
 5 files changed, 152 insertions(+), 2 deletions(-)

diff --git a/artmura-site/build-line.js b/artmura-site/build-line.js
index 2af85c0..beca050 100644
--- a/artmura-site/build-line.js
+++ b/artmura-site/build-line.js
@@ -19,6 +19,11 @@ const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
 const API = '2024-10';
 const VENDOR = process.argv[2];
 if (!TOKEN || !VENDOR) { console.error('usage: SHOPIFY_ADMIN_TOKEN=… node build-line.js "<Vendor>"'); process.exit(1); }
+// TK-11200 — showroom-only guard. A showroom line (Phillip Jeffries) must stay
+// "addressable but not discoverable"; a vendor microsite is a discovery surface, so the
+// factory refuses to snapshot one at all. Override: ALLOW_SHOWROOM=1 (logs loudly).
+const showroom = require('./showroom.js');
+if (!showroom.assertNotShowroom(VENDOR, 'building a vendor-landing snapshot')) process.exit(2);
 const slug = VENDOR.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
 const OUT = path.join(__dirname, 'lines');
 fs.mkdirSync(OUT, { recursive: true });
@@ -62,7 +67,7 @@ async function metafields(pid) {
   const prods = await getAll();
   console.log(`  ${prods.length} active products`);
   const products = [], handles = {};
-  let i = 0;
+  let i = 0, skippedShowroom = 0;
   for (const p of prods) {
     if (++i % 100 === 0) console.log(`  …${i}/${prods.length} products processed`);
     const mf = await metafields(p.id);
@@ -82,6 +87,9 @@ async function metafields(pid) {
       || cleanBase === `${VENDOR} ${VENDOR}`.toLowerCase()
       || !(p.images && p.images.length);
     if (isJunk) continue;
+    // defense in depth: drop individually showroom-TAGGED products even on a sellable
+    // vendor (shared-label lines like MDC under "Phillipe Romano").
+    if (showroom.isShowroomProduct({ vendor: p.vendor, tags })) { skippedShowroom++; continue; }
     if (sku) handles[sku.toUpperCase()] = p.handle;
     products.push({
       mfr_sku: mf.mfr_sku || sku,
@@ -109,8 +117,15 @@ async function metafields(pid) {
       published_at: p.published_at,
     });
   }
+  // never write an EMPTY snapshot over a good one — a bad/expired token or a vendor-name
+  // typo returns 0 products, and rsync'ing that to prod blanks a live microsite's grid.
+  if (!products.length) {
+    console.error(`\n[build-line] REFUSED: 0 products captured for "${VENDOR}" — not writing lines/${slug}.json.\n  Check the token and that the vendor name matches Shopify exactly. Override: ALLOW_EMPTY=1\n`);
+    if (process.env.ALLOW_EMPTY !== '1') process.exit(3);
+  }
   fs.writeFileSync(path.join(OUT, `${slug}.json`), JSON.stringify({ vendor: VENDOR, captured_count: products.length, products }, null, 2));
   fs.writeFileSync(path.join(OUT, `${slug}-handles.json`), JSON.stringify(handles, null, 1));
+  if (skippedShowroom) console.log(`  [showroom] excluded ${skippedShowroom} showroom-only product(s) from the snapshot`);
   console.log(`  → lines/${slug}.json (${products.length})  lines/${slug}-handles.json`);
   console.log(`\nPaste into site.config.js:\n`);
   console.log(`  ${slug}: {
diff --git a/artmura-site/config/showroom-vendors.json b/artmura-site/config/showroom-vendors.json
new file mode 100644
index 0000000..095e28f
--- /dev/null
+++ b/artmura-site/config/showroom-vendors.json
@@ -0,0 +1 @@
+["Phillip Jeffries"]
diff --git a/artmura-site/deploy-vendor.sh b/artmura-site/deploy-vendor.sh
index 13a331b..5818de1 100644
--- a/artmura-site/deploy-vendor.sh
+++ b/artmura-site/deploy-vendor.sh
@@ -14,9 +14,21 @@ DATA="$SITE/lines/${VENDOR}.json"
 HANDLES="$SITE/lines/${VENDOR}-handles.json"
 COLORS="$SITE/lines/${VENDOR}-colors.json"     # optional
 
+# TK-11200 — showroom-only guard. Refuse to stand up a public microsite (a discovery
+# surface) for a showroom-only line; those stay "addressable but not discoverable".
+# Checks the SLUG, the DOMAIN and the snapshot's own vendor field, so a renamed slug
+# or a mismatched domain can't slip past. Override: ALLOW_SHOWROOM=1.
+node -e '
+  const s = require(process.argv[4]), fs = require("fs");
+  const cand = new Set([process.argv[1], process.argv[2].split(".")[0]]);
+  try { cand.add(JSON.parse(fs.readFileSync(process.argv[3], "utf8")).vendor); } catch {}
+  for (const c of cand) if (c && !s.assertNotShowroom(c, "deploying a public vendor microsite")) process.exit(2);
+' "$VENDOR" "$DOMAIN" "$DATA" "$SITE/showroom.js" || exit 2
+
 [ -f "$DATA" ]    || { echo "missing $DATA — run: node build-line.js \"<Vendor>\" first"; exit 1; }
 [ -f "$HANDLES" ] || { echo "missing $HANDLES"; exit 1; }
 
+
 echo "→ resolving port (reuse if $PM2 already deployed, else a free one)"
 PORT=$(ssh "$KAM" "
   cur=\$(pm2 jlist 2>/dev/null | node -e \"try{const a=JSON.parse(require('fs').readFileSync(0));const p=a.find(x=>x.name==='$PM2');process.stdout.write(String((p&&p.pm2_env&&p.pm2_env.env&&p.pm2_env.env.PORT)||''))}catch(e){}\" 2>/dev/null)
diff --git a/artmura-site/server.js b/artmura-site/server.js
index 174bfa2..4c4c255 100644
--- a/artmura-site/server.js
+++ b/artmura-site/server.js
@@ -10,6 +10,15 @@ const VENDOR = process.env.VENDOR || 'artmura';
 const CFG = require('./site.config.js')[VENDOR];
 if (!CFG) { console.error(`No site.config for VENDOR=${VENDOR}`); process.exit(1); }
 
+// TK-11200 — showroom-only guard. A showroom line (Phillip Jeffries) stays "addressable
+// but not discoverable"; this app IS a discovery surface (a public, browsable, indexable
+// grid on its own subdomain), so it refuses to serve one. Checks the config's vendor/line
+// AND the VENDOR slug, so a renamed slug can't slip past. Override: ALLOW_SHOWROOM=1.
+const showroom = require('./showroom.js');
+for (const cand of [CFG.vendor, CFG.line, VENDOR]) {
+  if (cand && !showroom.assertNotShowroom(cand, 'serving a public vendor microsite')) process.exit(2);
+}
+
 // BUNDLE=1 → self-contained deploy: read data/images from alongside the app (./_data, ./_images),
 // so the host needs only this one directory (no repo-relative ../ paths).
 const BUNDLE = process.env.BUNDLE === '1';
@@ -50,7 +59,13 @@ function load() {
   let colors = {}, handles = {};
   try { colors = JSON.parse(fs.readFileSync(COLORS_PATH, 'utf8')); } catch {}
   try { handles = JSON.parse(fs.readFileSync(HANDLES_PATH, 'utf8')); } catch {}
-  CATALOG = d.products.map(r => {
+  // defense in depth: drop individually showroom-TAGGED products even on a sellable
+  // vendor (shared-label lines like MDC under "Phillipe Romano"). Snapshots built before
+  // the build-line.js guard existed can still carry them.
+  const before = d.products.length;
+  const rows = d.products.filter(r => !showroom.isShowroomProduct(r));
+  const droppedShowroom = before - rows.length;
+  CATALOG = rows.map(r => {
     const imgs = localImages(r);
     const c = colors[r.mfr_sku] || {};
     // build-line lines carry the live DW handle directly; Artmura maps newwall-sku → DW handle
@@ -86,6 +101,7 @@ function load() {
       published_at: r.published_at,
     };
   });
+  if (droppedShowroom) console.log(`[${VENDOR}] [showroom] hid ${droppedShowroom} showroom-only product(s) from the grid`);
   console.log(`[${VENDOR}] loaded ${CATALOG.length} products`);
 }
 load();
diff --git a/artmura-site/showroom.js b/artmura-site/showroom.js
new file mode 100644
index 0000000..32da025
--- /dev/null
+++ b/artmura-site/showroom.js
@@ -0,0 +1,106 @@
+/**
+ * Showroom-only guard for the DW vendor-landing generator (TK-11200).
+ *
+ * Steve's standing rule (showroom-line-steward): a SHOWROOM-ONLY line — currently
+ * Phillip Jeffries — must stay "addressable but not discoverable". It may be reached
+ * by a direct /products/<handle> URL or on-site search, but must NOT appear on any
+ * push/browse/discovery surface. A dedicated public vendor microsite (a full browsable
+ * grid on its own subdomain) IS a discovery surface, so the generator must refuse to
+ * mint one for a showroom-only vendor.
+ *
+ * Self-contained by design: this file is rsync'd with the app on a BUNDLE=1 deploy, so
+ * the deployed microsite carries its own copy of the rule (no repo-relative ../ reads).
+ * config/showroom-vendors.json is kept in sync with the canonical list at
+ * ~/Projects/fix-live-board/config/showroom-vendors.json.
+ *
+ * Mirrors dw-domain-fleet/shared/catalog.js so both fleets speak the same rule.
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const SHOWROOM_VENDORS = (() => {
+  try {
+    const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'config', 'showroom-vendors.json'), 'utf8'));
+    return (Array.isArray(raw) ? raw : []).map(v => norm(v)).filter(Boolean);
+  } catch { return []; }
+})();
+// squashed forms, so a SLUG or a SUBDOMAIN label ("philipjeffries", "phillip-jeffries")
+// matches the spaced vendor name ("Phillip Jeffries"). deploy-vendor.sh passes slugs.
+const SHOWROOM_SQUASHED = SHOWROOM_VENDORS.map(v => v.replace(/ /g, ''));
+
+// Normalize for comparison. Also collapses the well-known "Philip"/"Phillip" spelling
+// split — the live catalog carries BOTH ("Phillip Jeffries" vendor, "philip-jeffries"
+// handles), and a one-letter typo must not be a way past the guard.
+function norm(v) {
+  return String(v || '')
+    .trim().toLowerCase()
+    .replace(/[^a-z0-9]+/g, ' ')
+    .replace(/philip/g, 'phillip')   // single-L typo -> canonical; 'phillip' can't re-match
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+// same normalization with ALL separators removed — matches slugs/subdomain labels.
+function squash(v) { return norm(v).replace(/ /g, ''); }
+
+/** true if a vendor NAME (string) is a showroom-only line */
+function isShowroomVendorName(name) {
+  const n = norm(name), q = squash(name);
+  if (!n) return false;
+  return SHOWROOM_VENDORS.includes(n) || SHOWROOM_SQUASHED.includes(q);
+}
+
+/** true if a PRODUCT's vendor is a showroom-only line */
+function isShowroomVendor(p) {
+  return isShowroomVendorName(p && p.vendor);
+}
+
+/**
+ * Product-level showroom TAG (TK-11307). A shared-vendor showroom line (e.g. MDC under
+ * the shared "Phillipe Romano" label, which also carries SELLABLE lines) is hidden by its
+ * "Showroom" tag WITHOUT hiding the vendor's sellable products. Exact tag, not a token.
+ */
+function isShowroomTagged(p) {
+  const tags = p && p.tags;
+  const arr = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
+  return arr.some(t => String(t).trim().toLowerCase() === 'showroom');
+}
+
+/** showroom-only = vendor on the list OR carries the Showroom tag */
+function isShowroomProduct(p) {
+  return isShowroomVendor(p) || isShowroomTagged(p);
+}
+
+/**
+ * The escape hatch. Building or serving a showroom line as a microsite is a deliberate,
+ * Steve-gated act — never an accident. ALLOW_SHOWROOM=1 opts in and always logs loudly.
+ */
+function showroomOverride() {
+  return process.env.ALLOW_SHOWROOM === '1';
+}
+
+/**
+ * Hard gate used by build-line.js / server.js / deploy-vendor.sh.
+ * Returns true when the caller may proceed; otherwise prints why and returns false.
+ */
+function assertNotShowroom(vendorName, what) {
+  if (!isShowroomVendorName(vendorName)) return true;
+  if (showroomOverride()) {
+    console.warn(`[showroom] ⚠ ALLOW_SHOWROOM=1 — proceeding with ${what} for SHOWROOM-ONLY vendor "${vendorName}". This publishes a discovery surface for a showroom line.`);
+    return true;
+  }
+  console.error(
+    `\n[showroom] REFUSED: "${vendorName}" is a SHOWROOM-ONLY line.\n` +
+    `  ${what} would create a public, browsable discovery surface for it, which breaks the\n` +
+    `  "addressable but not discoverable" rule (showroom-line-steward).\n` +
+    `  Showroom-only vendors: ${SHOWROOM_VENDORS.join(', ') || '(none configured)'}\n` +
+    `  If this is deliberate and Steve-approved, re-run with ALLOW_SHOWROOM=1.\n`
+  );
+  return false;
+}
+
+module.exports = {
+  SHOWROOM_VENDORS, SHOWROOM_SQUASHED, norm, squash,
+  isShowroomVendorName, isShowroomVendor, isShowroomTagged, isShowroomProduct,
+  showroomOverride, assertNotShowroom,
+};

← 20ab7b0 TK-11186: trending-tag-sync twin — guard showroom-only vendo  ·  back to Dw Yolo Loop  ·  deploy-vendor.sh: gate -internal hosts by default (TK-11391 4c10120 →