← 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
M artmura-site/build-line.jsA artmura-site/config/showroom-vendors.jsonM artmura-site/deploy-vendor.shM artmura-site/server.jsA artmura-site/showroom.js
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 →