[object Object]

← back to All Designerwallcoverings

serve every unified vendor through shared host endpoints

6032ba81468c385ea74034b7fc9061fc24330ef9 · 2026-08-28 09:17:45 -0700 · Steve Abrams

Files touched

Diff

commit 6032ba81468c385ea74034b7fc9061fc24330ef9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Aug 28 09:17:45 2026 -0700

    serve every unified vendor through shared host endpoints
---
 lib/vendor-host.js                     | 29 +++++++++++++++++++
 ops/nginx/unified-vendor-fallback.conf | 27 +++++++++++++++++
 server.js                              | 53 +++++++++++++++++++++++++---------
 test/vendor-host.test.js               | 25 ++++++++++++++++
 4 files changed, 120 insertions(+), 14 deletions(-)

diff --git a/lib/vendor-host.js b/lib/vendor-host.js
new file mode 100644
index 0000000..acef83e
--- /dev/null
+++ b/lib/vendor-host.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const { hostSlugify } = require('./vendor-pairs');
+const BASE = '.designerwallcoverings.com';
+
+function buildVendorHostMap(vendors, productRows) {
+  const map = new Map();
+  const put = (name, explicitSlug) => {
+    const vendor = String(name || '').trim();
+    const slug = hostSlugify(explicitSlug || vendor);
+    if (vendor && slug && !map.has(slug)) map.set(slug, vendor);
+  };
+  for (const row of vendors || []) put(row.name || row.vendor, row.site_slug);
+  for (const row of productRows || []) put(row.vendor);
+  return map;
+}
+
+function vendorHostContext(hostHeader, vendorMap) {
+  const host = String(hostHeader || '').split(':')[0].toLowerCase();
+  if (!host.endsWith(BASE)) return null;
+  let slug = host.slice(0, -BASE.length);
+  if (!slug || slug === 'all' || slug.includes('.')) return null;
+  const internal = slug.endsWith('-internal');
+  if (internal) slug = slug.slice(0, -'-internal'.length);
+  const vendor = vendorMap.get(slug);
+  return vendor ? { vendor, slug, internal, host } : { vendor: null, slug, internal, host };
+}
+
+module.exports = { buildVendorHostMap, vendorHostContext };
diff --git a/ops/nginx/unified-vendor-fallback.conf b/ops/nginx/unified-vendor-fallback.conf
new file mode 100644
index 0000000..f465bc4
--- /dev/null
+++ b/ops/nginx/unified-vendor-fallback.conf
@@ -0,0 +1,27 @@
+# Shared fallback for unified vendor hostnames. Existing exact server_name blocks
+# always win over this regex, so dedicated vendor applications remain untouched.
+server {
+    listen 45.61.58.125:443 ssl http2;
+    server_name ~^(?<unified_vendor>[a-z0-9-]+)\.designerwallcoverings\.com$;
+
+    ssl_certificate /etc/letsencrypt/live/designerwallcoverings.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/designerwallcoverings.com/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+    location / {
+        proxy_pass http://127.0.0.1:9958;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 120s;
+    }
+}
+
+server {
+    listen 80;
+    server_name ~^(?<unified_vendor>[a-z0-9-]+)\.designerwallcoverings\.com$;
+    return 301 https://$host$request_uri;
+}
diff --git a/server.js b/server.js
index b4eeeae..3d765f3 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,7 @@ const { execFile } = require('child_process');
 const { Pool } = require('pg');
 try { require('dotenv').config(); } catch {}
 const { crawlMicrosites, OUT: MICROSITES } = require('./scripts/crawl-microsites');
+const { buildVendorHostMap, vendorHostContext } = require('./lib/vendor-host');
 
 const PORT = process.env.PORT || 9958;
 const PUB = path.join(__dirname, 'public');
@@ -244,6 +245,7 @@ async function snapSql() {
 }
 
 let ROWS = [];
+let ROWS_PUBLIC_ACTIVE = [];
 let ROWS_INTERNAL = [];         // internal full-catalog feed (every non-archived SKU, leak-sanitized)
 let MICROSITE_ROWS = [];        // extra searchable rows sourced from DW-family microsite full-feeds
                                 // (customer-facing luxury lines NOT in shopify_products — e.g. Fromental,
@@ -258,6 +260,7 @@ let STOCK = new Map();          // upper(sku) → public-safe stored stock snaps
 let MFR = new Map();            // upper(bare dw_sku) → manufacturer SKU, backfilled from *_catalog tables
 let VENDOR_VIEWER = new Map();  // slugify(vendor) / slug → internal line-viewer URL (the vendor's live microsite)
 let VENDOR_ITEM = new Map();    // slugify(vendor) / slug → { url, handles:Set } — the microsite's FULL handle set;
+let VENDOR_HOSTS = new Map();   // canonical hostname slug → unified display vendor
                                // "This Item" deep-links resolve ONLY when the row's handle is IN that set.
 
 // Line-viewer OVERRIDES (crawl-resistant). buildVendorViewer() derives the viewer map from the
@@ -895,6 +898,7 @@ async function loadSnapshot() {
     // ...and every genuine catalog-only vendor line, minus the banned private-label mills.
     .concat(CATALOG_ROWS.filter((r) => !BANNED.test(r.vendor || '') && !BANNED.test(r.title || ''))),
     liveVendorKeys);
+  ROWS_PUBLIC_ACTIVE = ROWS.filter((r) => r.lifecycle === 'Active on site');
   // INTERNAL feed — EVERY non-archived SKU (incl. private-label, NO banned-exclusion), leak-sanitized,
   // plus the extra fields internal consumers (photo + substitute apps) need but the public API omits:
   // product_id (numeric, for Shopify photo push), handle (for product-page URLs), tags (for material/
@@ -925,6 +929,11 @@ async function loadSnapshot() {
   let fmOnly = 0;
   for (const [k, fm] of FM) { if (!fm.is_discontinued && (!fm.record_type || fm.record_type === 'Master') && !FM_MATCHED.has(k)) fmOnly++; }
   FM_STATS.fm_only_live = fmOnly;
+  // Host routing is derived from the same unified snapshot as the directory, with
+  // product-row vendors as a defensive fill. No hostname registry can drift separately.
+  let unifiedVendors = [];
+  try { unifiedVendors = JSON.parse(fs.readFileSync(DATA, 'utf8')).vendors || []; } catch {}
+  VENDOR_HOSTS = buildVendorHostMap(unifiedVendors, ROWS);
   LOADED_AT = new Date().toISOString();
   console.log(`snapshot: ${ROWS.length.toLocaleString()} public (incl. ${MICROSITE_ROWS.length.toLocaleString()} microsite + ${CATALOG_ROWS.length.toLocaleString()} catalog) + ${ROWS_INTERNAL.length.toLocaleString()} internal products in ${Date.now() - t0}ms (stock ${STOCK.size.toLocaleString()} · viewers ${VENDOR_VIEWER.size})`);
   console.log(`  filemaker: ${FM_STATS.rows.toLocaleString()} mirror rows · ${FM_STATS.matched.toLocaleString()} joined · ${FM_STATS.mismatch.toLocaleString()} mfr# mismatches · ${discoHidden.toLocaleString()} disco-hidden · ${fmOnly.toLocaleString()} FM-only live`);
@@ -1587,21 +1596,35 @@ const BASIC_AUTH = process.env.BASIC_AUTH
       ? `${process.env.BASIC_AUTH_USER}:${process.env.BASIC_AUTH_PASS}`
       : 'admin:DW2024!');
 const AUTH_OK = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+const publicVendorPath = (p) => p === '/' || p === '/index.html' || p === '/healthz'
+  || p === '/api/products' || p === '/api/facets' || p === '/drill.js'
+  || p.startsWith('/nav-agent/');
 
 const server = http.createServer((req, res) => {
   const u = new URL(req.url, `http://localhost:${PORT}`);
+  const vendorHost = vendorHostContext(req.headers.host, VENDOR_HOSTS);
 
-  if (u.pathname !== '/healthz' && req.headers.authorization !== AUTH_OK) {
+  // A hostname that reached the shared fallback but is not a unified vendor must
+  // never expose the all.dw catalog or its internal APIs.
+  if (vendorHost && !vendorHost.vendor) {
+    res.writeHead(404, { 'Content-Type': 'text/plain', 'X-Robots-Tag': 'noindex' });
+    return res.end('Unknown vendor endpoint');
+  }
+
+  const publicVendorRequest = vendorHost && !vendorHost.internal && publicVendorPath(u.pathname);
+  if (u.pathname !== '/healthz' && !publicVendorRequest && req.headers.authorization !== AUTH_OK) {
     res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Designer Wallcoverings - All Products"', 'Content-Type': 'text/plain' });
     return res.end('Authentication required');
   }
 
   if (u.pathname === '/api/products') {
     const f = parseFilters(u);
+    if (vendorHost) f.vendors = new Set([vendorHost.vendor]);
     const sort = u.searchParams.get('sort') || 'newest';
     const offset = parseInt(u.searchParams.get('offset') || '0', 10) || 0;
     const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10) || 120, 500);
-    let rows = applyFilters(ROWS, f);
+    const catalogRows = vendorHost && !vendorHost.internal ? ROWS_PUBLIC_ACTIVE : ROWS;
+    let rows = applyFilters(catalogRows, f);
     if (sorters[sort]) {
       rows = [...rows].sort(sorters[sort]);
       // dir=desc flips ANY sort key so every list-view column sorts both ways.
@@ -1619,7 +1642,7 @@ const server = http.createServer((req, res) => {
     }
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify({
-      total: rows.length, catalog: ROWS.length, loaded_at: LOADED_AT, offset, limit,
+      total: rows.length, catalog: vendorHost ? rows.length : ROWS.length, loaded_at: LOADED_AT, offset, limit,
       // keep created/updated (epoch ms) so the card/list can show the active-or-staged date; only `hay` is internal
       rows: rows.slice(offset, offset + limit).map(({ hay, ...r }) => r),
     }));
@@ -1647,20 +1670,22 @@ const server = http.createServer((req, res) => {
 
   if (u.pathname === '/api/facets') {
     const f = parseFilters(u);
+    if (vendorHost) f.vendors = new Set([vendorHost.vendor]);
+    const facetRows = vendorHost && !vendorHost.internal ? ROWS_PUBLIC_ACTIVE : ROWS;
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify({
-      vendor: tally(applyFilters(ROWS, f, { skip: 'vendor' }), 'vendor'),
-      type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'type'),
-      series: tally(applyFilters(ROWS, f, { skip: 'series' }), 'series'),
-      lifecycle: tally(applyFilters(ROWS, f, { skip: 'lifecycle' }), 'lifecycle'),
-      status: tally(applyFilters(ROWS, f, { skip: 'status' }), 'status'),
-      color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
-      style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'styles'),
-      material: tallyArr(applyFilters(ROWS, f, { skip: 'material' }), 'materials'),
-      price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
-      image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+      vendor: tally(applyFilters(facetRows, f, vendorHost ? {} : { skip: 'vendor' }), 'vendor'),
+      type: tally(applyFilters(facetRows, f, { skip: 'type' }), 'type'),
+      series: tally(applyFilters(facetRows, f, { skip: 'series' }), 'series'),
+      lifecycle: tally(applyFilters(facetRows, f, { skip: 'lifecycle' }), 'lifecycle'),
+      status: tally(applyFilters(facetRows, f, { skip: 'status' }), 'status'),
+      color: tallyArr(applyFilters(facetRows, f, { skip: 'color' }), 'colors'),
+      style: tallyArr(applyFilters(facetRows, f, { skip: 'style' }), 'styles'),
+      material: tallyArr(applyFilters(facetRows, f, { skip: 'material' }), 'materials'),
+      price_band: tally(applyFilters(facetRows, f, { skip: 'price' }), 'price_band'),
+      image_state: tally(applyFilters(facetRows, f, { skip: 'image' }), 'image_state'),
       price_order: PRICE_ORDER, family_order: FAMILY_ORDER, lifecycle_order: LIFE_ORDER,
-      total: applyFilters(ROWS, f).length,
+      total: applyFilters(facetRows, f).length,
       filemaker: FM_STATS,
     }));
     return;
diff --git a/test/vendor-host.test.js b/test/vendor-host.test.js
new file mode 100644
index 0000000..528027b
--- /dev/null
+++ b/test/vendor-host.test.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { buildVendorHostMap, vendorHostContext } = require('../lib/vendor-host');
+
+const hosts = buildVendorHostMap([{ name: 'Farrow & Ball' }, { name: 'Astek' }], []);
+
+test('public and internal twins resolve to the same unified vendor', () => {
+  assert.deepEqual(vendorHostContext('farrow-and-ball.designerwallcoverings.com', hosts), {
+    vendor: 'Farrow & Ball', slug: 'farrow-and-ball', internal: false,
+    host: 'farrow-and-ball.designerwallcoverings.com',
+  });
+  assert.equal(vendorHostContext('farrow-and-ball-internal.designerwallcoverings.com', hosts).vendor, 'Farrow & Ball');
+  assert.equal(vendorHostContext('farrow-and-ball-internal.designerwallcoverings.com', hosts).internal, true);
+});
+
+test('unknown wildcard hosts are explicit non-vendors', () => {
+  const ctx = vendorHostContext('not-a-real-vendor.designerwallcoverings.com', hosts);
+  assert.equal(ctx.vendor, null);
+});
+
+test('all.dw remains outside vendor fallback routing', () => {
+  assert.equal(vendorHostContext('all.designerwallcoverings.com', hosts), null);
+});

← 10fd1a2 record live vendor pair deployment proof  ·  back to All Designerwallcoverings  ·  probe shared vendor pairs without downloading duplicate feed 30003d2 →