[object Object]

← back to 1980swallpaper

Restore vendor redaction + server-side sort in static-only server.js

c10dbfb3d9ed5a9d410722002b7714ab334148df · 2026-09-15 08:50:33 -0700 · Steve

The static-JSON rewrite dropped three things the rest of the site still needs:
- api-vendor-redact middleware (real vendors Fabricut/Thibaut/Newmor were
  leaking via /api/facets + /api/products; handle_display was no longer
  injected, so /sample links fell back to raw vendor-bearing handles).
  Restored the fleet-standard _shared/api-vendor-redact + dual-key /sample
  resolver — matches the 60+ sibling microsites.
- Server-side sort (sortProducts + helpers): the frontend #sortSelect still
  sends ?sort= with no client-side re-sort, so every sort option was a no-op.
- parseInt fallbacks: malformed ?page=/?limit= yielded NaN -> broken response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xRrtNCXtWzEhrtV6HV6S7

Files touched

Diff

commit c10dbfb3d9ed5a9d410722002b7714ab334148df
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 15 08:50:33 2026 -0700

    Restore vendor redaction + server-side sort in static-only server.js
    
    The static-JSON rewrite dropped three things the rest of the site still needs:
    - api-vendor-redact middleware (real vendors Fabricut/Thibaut/Newmor were
      leaking via /api/facets + /api/products; handle_display was no longer
      injected, so /sample links fell back to raw vendor-bearing handles).
      Restored the fleet-standard _shared/api-vendor-redact + dual-key /sample
      resolver — matches the 60+ sibling microsites.
    - Server-side sort (sortProducts + helpers): the frontend #sortSelect still
      sends ?sort= with no client-side re-sort, so every sort option was a no-op.
    - parseInt fallbacks: malformed ?page=/?limit= yielded NaN -> broken response.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_016xRrtNCXtWzEhrtV6HV6S7
---
 server.js | 264 ++++++++++++++++----------------------------------------------
 1 file changed, 67 insertions(+), 197 deletions(-)

diff --git a/server.js b/server.js
index af4b4b9..15bb7b6 100644
--- a/server.js
+++ b/server.js
@@ -1,73 +1,20 @@
 /**
  * 1980s WALLPAPER — DW family vertical
  * Curated slice from live designerwallcoverings.com Shopify catalog.
+ * Static-JSON server (data/products.json). Vendor identity is scrubbed at the
+ * API boundary by the fleet-standard _shared/api-vendor-redact middleware — the
+ * same layer the 60+ sister microsites use — so no vendor name reaches the client.
  */
-try { require('dotenv').config({ path: require('path').join(__dirname, '.env') }); } catch (e) {}
 const express = require('express');
 const helmet = require('helmet');
 const path = require('path');
 const fs = require('fs');
+const vendorRedact = require('../_shared/api-vendor-redact');
+const { redactVendorText } = require('../_shared/vendor-text-redact');
 
 const PORT = process.env.PORT || 9840;
 const DW_SHOPIFY = 'https://designerwallcoverings.com';
-const __SITE = path.basename(__dirname);
-// Admin catalog (PG-backed) is opt-in via MICROSITE_ADMIN_ENABLED=true. Prod
-// stays static-only (data/products.json) so Kamatera doesn't need `pg` or the
-// _shared/ tree. Dev (Mac2) flips the flag on to use dw_unified + /admin/catalog.
-const ADMIN_ENABLED = process.env.MICROSITE_ADMIN_ENABLED === 'true';
-let catalog = null;
-if (ADMIN_ENABLED) {
-  try { catalog = require('../_shared/admin-catalog'); }
-  catch (e) { console.error(`[${__SITE}] admin-catalog unavailable (${e.message}) — falling back to static JSON`); }
-}
-const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
-const SITE_SLUG = siteCfg.slug || __SITE;
-const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
-
-// Read-time rail membership: a product belongs to a rail if its `aesthetic` OR
-// any `tag` equals the rail key (or a declared synonym). Sister-site catalogs
-// have a degenerate `aesthetic` (here: "all"), so naive `p.aesthetic === key`
-// matching collapses every rail to empty — the richer signal lives in `tags`.
-// Prefer the shared matcher on dev; fall back to a self-contained copy on prod
-// where the _shared/ tree isn't deployed.
-let railMatch;
-try {
-  railMatch = require('../_shared/rail-match');
-} catch (e) {
-  const norm = s => String(s == null ? '' : s).trim().toLowerCase();
-  const productInRail = (p, key, syn) => {
-    const vals = [norm(key)].concat((syn && Array.isArray(syn[key]) ? syn[key] : []).map(norm));
-    if (vals.includes(norm(p && p.aesthetic))) return true;
-    const tags = p && Array.isArray(p.tags) ? p.tags : [];
-    return tags.some(t => vals.includes(norm(t)));
-  };
-  railMatch = {
-    productInRail,
-    buildRails(products, railKeys, opts) {
-      opts = opts || {}; const syn = opts.synonyms || null;
-      const minShare = opts.minShare != null ? opts.minShare : 0.08;
-      const minItems = opts.minItems != null ? opts.minItems : 4;
-      const perRail = opts.perRail != null ? opts.perRail : 12;
-      const maxShare = opts.maxShare != null ? opts.maxShare : 0.99;
-      const list = Array.isArray(products) ? products : []; const N = list.length || 1; const out = [];
-      for (const key of (railKeys || [])) {
-        const m = list.filter(p => productInRail(p, key, syn)); const c = m.length;
-        if (c >= minItems && c / N >= minShare && c / N <= maxShare) out.push({ key, aesthetic: key, count: c, items: m.slice(0, perRail) });
-      }
-      return out;
-    },
-    railFacets(products, railKeys, syn) {
-      const list = Array.isArray(products) ? products : []; const f = {};
-      for (const key of (railKeys || [])) f[key] = list.filter(p => productInRail(p, key, syn || null)).length;
-      return f;
-    }
-  };
-}
-const { productInRail, buildRails, railFacets } = railMatch;
-// Synonyms come from the site's own config (prod-safe); the shared default map
-// is a dev-only convenience when _shared/ is present.
-let RAIL_SYN = siteCfg.railSynonyms || {};
-if (!siteCfg.railSynonyms) { try { RAIL_SYN = require('../_shared/rail-synonyms.json'); } catch (e) {} }
+const DATA_RAW = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
 
 function isJunk(p) {
   if (!p.image_url || !p.image_url.trim()) return true;
@@ -80,26 +27,13 @@ function isJunk(p) {
   return false;
 }
 
-// Catalog now lives in dw_unified.microsite_products — populated async at
-// startup (see bottom of file). Falls back to data/products.json if PG is down.
-let PRODUCTS = [];
-let DROPPED = 0;
-
-// graphics-loop pass 12: niche keyword filter — only show products that fit this site's niche
-const NICHE_POS = ["memphis","postmodern","neon","1980","geometric","abstract"];
-const NICHE_NEG = ["solid","blank","plain"];
-function nicheFit(p) {
-  const blob = ((p.title || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
-  if (NICHE_NEG.some(n => blob.includes(n.toLowerCase()))) return false;
-  return NICHE_POS.length === 0 || NICHE_POS.some(k => blob.includes(k.toLowerCase()));
-}
-// Niche-filtered slice — recomputed once the catalog loads (see startup IIFE).
-let PRODUCTS_NICHE = [];
-
+const PRODUCTS = DATA_RAW.filter(p => !isJunk(p));
+const DROPPED = DATA_RAW.length - PRODUCTS.length;
+console.log(`Loaded ${DATA_RAW.length}, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
 
-const app = express();
-// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
-// DW STANDARD: sort by color/style/sku/title for any product-rendering site.
+// DW STANDARD: every product grid is sortable by color/style/sku/title/price/
+// light-dark. The frontend #sortSelect drives these server-side (no client-side
+// re-sort), so the sort machinery must live here.
 const COLOR_RANK = {
   black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,khaki:9,copper:10,
   red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
@@ -147,18 +81,6 @@ function dominantHex(p){
   for (const t of tags){const hx=rgbOfTag(t);if(hx)return hx;}
   return '#888';
 }
-// Up to 4 distinct color dots: real-pixel hex first, then tag-derived hexes.
-function colorDots(p){
-  const seen=new Set();const out=[];
-  if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) {
-    out.push(p.dominant_hex_real); seen.add(p.dominant_hex_real.toLowerCase());
-  }
-  for (const t of (p.tags||[])){
-    const hx=rgbOfTag(t);
-    if(hx && !seen.has(hx.toLowerCase())){seen.add(hx.toLowerCase());out.push(hx);if(out.length>=4)break;}
-  }
-  return out;
-}
 function luminance(p){const rgb=hexToRgb(dominantHex(p));return rgb?(0.2126*rgb[0]+0.7152*rgb[1]+0.0722*rgb[2]):128;}
 function hue(p){const rgb=hexToRgb(dominantHex(p));if(!rgb)return 999;const [h,s]=rgbToHsl(rgb[0],rgb[1],rgb[2]);return s<0.08?999:h;}
 
@@ -172,112 +94,88 @@ function sortProducts(list, mode) {
   if (mode === 'wheel') return [...list].sort((a,b) => hue(a) - hue(b));
   if (mode === 'price-asc')  return [...list].sort((a,b) => (Number(a.max_price) || 0) - (Number(b.max_price) || 0));
   if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.max_price) || 0) - (Number(a.max_price) || 0));
-  // List-view sortable columns — every column sortable in both directions.
   if (mode === 'title-desc')     return [...list].sort((a,b) => String(b.title || '').localeCompare(String(a.title || '')));
   if (mode === 'sku-desc')       return [...list].sort((a,b) => String(b.sku || b.handle || '').localeCompare(String(a.sku || a.handle || '')));
-  if (mode === 'vendor')         return [...list].sort((a,b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
-  if (mode === 'vendor-desc')    return [...list].sort((a,b) => String(b.vendor || '').localeCompare(String(a.vendor || '')) || String(a.title || '').localeCompare(String(b.title || '')));
   if (mode === 'aesthetic')      return [...list].sort((a,b) => String(a.aesthetic || '').localeCompare(String(b.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
   if (mode === 'aesthetic-desc') return [...list].sort((a,b) => String(b.aesthetic || '').localeCompare(String(a.aesthetic || '')) || String(a.title || '').localeCompare(String(b.title || '')));
   return list;
 }
 
+// Dual-key /sample resolver: the client links with the vendor-scrubbed
+// `handle_display` (= redactVendorText(handle), the same value the redact
+// middleware injects into API responses). Map that redacted slug back to the
+// product so the sample link resolves without the raw vendor-bearing handle
+// ever riding in the URL bar.
+let SAMPLE_DISPLAY = new Map();
+(function buildSampleDisplayMap() {
+  const map = new Map();
+  const collided = new Set();
+  for (const p of PRODUCTS) {
+    for (const key of [p.handle, p.sku]) {
+      if (!key) continue;
+      const d = redactVendorText(String(key));
+      if (!d || d === key) continue;           // nothing redacted — raw route already matches
+      const prev = map.get(d);
+      if (prev && prev !== p) { collided.add(d); continue; }
+      map.set(d, p);
+    }
+  }
+  for (const d of collided) map.delete(d);       // ambiguous slug — keep raw-only
+  SAMPLE_DISPLAY = map;
+  console.log(`[1980swallpaper] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
+})();
 
+const app = express();
+// Security headers (contentSecurityPolicy disabled — the storefront inlines styles/scripts).
 app.use(helmet({ contentSecurityPolicy: false }));
-app.use(express.json({ limit: '256kb' }));
-
-// Fleet-wide DW vendor-name redactor on /api/* (drops `vendors` facet, replaces
-// any `vendor` field with "Designer Wallcoverings", strips `?vendor=` query).
-app.use(require('../_shared/api-vendor-redact'));
-// Universal contact module — modals, /api/send-inquiry, /api/send-sample, /zd-loader.js
-require('./_universal-contact')(app, { siteName: "1980s Wallpaper", zdColor: "#ff007a", zdPosition: 'right' });
-require('./_universal-auth')(app, { siteName: "1980swallpaper" });
-
-// Block snapshot/backup files from ever serving out of the static root.
-app.use((req, res, next) => {
-  if (/\.(bak|pre-[\w.-]+|orig|tmp)$|\.bak[\-.]|\.pre-/i.test(req.path)) return res.status(404).send('Not found');
-  next();
-});
-
-// Clean URLs: redirect old /care.html -> /care (301), then serve extensionless.
-app.get(/^\/(.+)\.html$/i, (req, res) => {
-  const qi = req.originalUrl.indexOf('?');
-  const search = qi >= 0 ? req.originalUrl.slice(qi) : '';
-  if (req.path === '/index.html') return res.redirect(301, '/' + search);
-  return res.redirect(301, '/' + req.params[0] + search);
-});
-// Clean URLs: serve /care -> care.html etc.
-require('../_shared/_universal-promo-banner')(app, {}); // new-arrivals promo strip (Tier B)
-app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
+// Fleet vendor redactor: scrubs vendor/vendors/title/sku/tags from every /api/*
+// JSON body, injects handle_display, and serves the vendor-neutral /img/ proxy.
+app.use(vendorRedact);
+if (typeof vendorRedact.warm === 'function') vendorRedact.warm(PRODUCTS); // pre-warm /img token map (cold-start 404 fix)
+app.use(express.static(path.join(__dirname, 'public')));
 
 app.get('/api/products', (req, res) => {
-  const { q, aesthetic, vendor, page = 1, limit = 24, sort = 'newest'} = req.query;
-  let list = PRODUCTS_NICHE;
+  const { q, aesthetic, page = 1, limit = 24, sort = 'newest' } = req.query;
+  let list = PRODUCTS;
   if (q) {
     const needle = q.toLowerCase();
-    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.description || p.body_html || '').toLowerCase().includes(needle) || (p.product_type || '').toLowerCase().includes(needle) || (p.vendor || '').toLowerCase().includes(needle) || (p.sku || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
+    list = list.filter(p => (p.title || '').toLowerCase().includes(needle) || (p.tags || []).some(t => t.toLowerCase().includes(needle)));
   }
-  if (aesthetic && aesthetic !== 'all') list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
-  if (vendor && vendor !== 'all') list = list.filter(p => p.vendor === vendor);
+  if (aesthetic && aesthetic !== 'all') list = list.filter(p => p.aesthetic === aesthetic);
   list = sortProducts(list, sort);
   const total = list.length;
-  const pageNum = Math.max(1, parseInt(page) || 1);
-  const lim = Math.min(60, parseInt(limit) || 24);
+  const pageNum = Math.max(1, parseInt(page, 10) || 1);
+  const lim = Math.min(60, parseInt(limit, 10) || 24);
   const start = (pageNum - 1) * lim;
-  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total/lim), sort, items: list.slice(start, start + lim) });
-  // (replaced) old: pages: Math.ceil(total / lim), items: list.slice(start, start + lim) });
+  res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total / lim), sort, items: list.slice(start, start + lim) });
 });
 
 app.get('/api/sliders', (req, res) => {
-  const rails = buildRails(PRODUCTS_NICHE, SITE_RAILS, { synonyms: RAIL_SYN });
-  res.json({ rails: rails.map(r => ({ aesthetic: r.aesthetic, items: r.items })) });
+  const SLIDER_AESTHETICS = ["memphis","neon","geometric","stripe","abstract","pop"];
+  const out = [];
+  for (const a of SLIDER_AESTHETICS) {
+    const items = PRODUCTS.filter(p => p.aesthetic === a).slice(0, 12);
+    if (items.length >= 4) out.push({ aesthetic: a, items });
+  }
+  res.json({ rails: out });
 });
 
 app.get('/api/facets', (req, res) => {
-  const aesthetics = railFacets(PRODUCTS_NICHE, SITE_RAILS, RAIL_SYN);
-  const vendors = {};
-  for (const p of PRODUCTS_NICHE) {
+  const aesthetics = {}; const vendors = {};
+  for (const p of PRODUCTS) {
+    aesthetics[p.aesthetic] = (aesthetics[p.aesthetic] || 0) + 1;
     vendors[p.vendor] = (vendors[p.vendor] || 0) + 1;
   }
-  res.json({ aesthetics, vendors, total: PRODUCTS_NICHE.length });
+  // `vendors` is dropped by the redact middleware before it reaches the client.
+  res.json({ aesthetics, vendors, total: PRODUCTS.length });
 });
 
-app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
-
-// Step 2 of the vendor handle/sku text-leak fix (2026-06-09): the client's
-// /sample/ hrefs now point at the vendor-scrubbed `handle_display` slug
-// (emitted by _shared/api-vendor-redact since Step 1), so the raw vendor-bearing
-// handle never rides in the URL bar. This route is DUAL-KEY — it resolves the raw
-// handle/sku FIRST (old bookmarks + buy path, byte-identical behavior), then falls
-// back to a redacted->product map built after catalog load. If two raw handles
-// redact to the same display slug, that slug is logged and left raw-only.
-const { redactVendorText } = require('../_shared/vendor-text-redact');
-let SAMPLE_DISPLAY = new Map(); // redacted display slug -> product
-function buildSampleDisplayMap() {
-  const map = new Map();
-  const collided = new Set();
-  for (const p of PRODUCTS_NICHE) {
-    for (const key of [p.handle, p.sku]) {
-      if (!key) continue;
-      const d = redactVendorText(String(key));
-      if (!d || d === key) continue;           // nothing redacted — raw route already matches
-      const prev = map.get(d);
-      if (prev && prev !== p) { collided.add(d); continue; }
-      map.set(d, p);
-    }
-  }
-  for (const d of collided) {
-    map.delete(d);
-    console.warn(`[1980swallpaper] /sample display-slug collision — keeping raw-only for "${d}"`);
-  }
-  SAMPLE_DISPLAY = map;
-  console.log(`[1980swallpaper] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
-}
+app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS.length, dropped: DROPPED }));
 
 app.get('/sample/:handle', (req, res) => {
   const key = req.params.handle;
-  const p = PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key) // raw first — old behavior intact
-    || SAMPLE_DISPLAY.get(key);                                        // then the redacted display form
+  const p = PRODUCTS.find(x => x.handle === key || x.sku === key) // raw first — old bookmarks + buy path
+    || SAMPLE_DISPLAY.get(key);                                   // then the redacted display slug
   if (!p) return res.status(404).send('Not found');
   res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
 });
@@ -298,34 +196,6 @@ ${urls.map(u => `  <url><loc>https://1980swallpaper.com${u}</loc><changefreq>wee
   res.type('application/xml').send(xml);
 });
 
-// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
-if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
-
-// Load the catalog. When admin is enabled, prefer dw_unified; else static JSON.
-(async () => {
-  let loaded = false;
-  if (catalog) {
-    try {
-      const rows = await catalog.getProducts(SITE_SLUG);
-      PRODUCTS = rows.filter(p => !isJunk(p) && p.visible !== false);
-      DROPPED = rows.length - PRODUCTS.length;
-      console.log(`[${__SITE}] loaded ${rows.length} from dw_unified, kept ${PRODUCTS.length}, dropped ${DROPPED}`);
-      loaded = true;
-    } catch (e) {
-      console.error(`[${__SITE}] PG catalog load failed (${e.message}) — falling back to data/products.json`);
-    }
-  }
-  if (!loaded) {
-    try {
-      const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
-      PRODUCTS = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p) && p.visible !== false);
-      console.log(`[${__SITE}] loaded ${PRODUCTS.length} from data/products.json (static mode)`);
-    } catch (_) { PRODUCTS = []; }
-  }
-  PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
-  console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
-  buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
-  app.listen(PORT, '127.0.0.1', () => {
-    console.log(`1980swallpaper listening on http://127.0.0.1:${PORT}`);
-  });
-})();
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`1980swallpaper listening on http://127.0.0.1:${PORT}`);
+});

← 6fef4ec auto-data-snapshot: 2026-09-15T08:44:47 (2 data files) — pac  ·  back to 1980swallpaper  ·  chore: v0.1.1 (session close — server.js vendor-redaction + dc1dd79 →