← back to Dw Internal Gateway

server.js

500 lines

// dw-internal-gateway — hostname-routed internal vendor sites.
//
//   <vendor>.internal.dw  -> dedicated viewer (routes.json) OR generic
//                            catalog viewer over dw_unified.<vendor>_catalog
//   internal.dw           -> index of every vendor
//
// Internal-only: binds 127.0.0.1. Basic auth admin/DW2024! at the edge;
// Authorization is forwarded so downstream apps using the same creds work.

const http = require('http');
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const PORT = Number(process.env.PORT || 80);
const BIND = process.env.IGW_BIND || '127.0.0.1'; // loopback-only; ambient $BIND must not flip this public
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const CFG = JSON.parse(fs.readFileSync(path.join(__dirname, 'routes.json'), 'utf8'));
const ROUTES = CFG.routes;
// slug -> "targethost.internal.dw/?query" alias. 302's to another internal host,
// optionally pre-filtered, so a line whose data already lives (with images) in a
// shared viewer isn't duplicated into an imageless generic <slug>_catalog view.
const REDIRECTS = CFG.redirects || {};

const pool = new Pool({ host: '/tmp', database: 'dw_unified', max: 5 });

// ---------- all.dw vendor routing (the canonical schema/UI for vendor products) ----------
// <vendor>.internal.dw for any vendor in shopify_products = the LOCAL all.dw instance
// (pm2 all-dw-local :9958) pre-filtered to that vendor — identical rows/cards/facets/sorts
// to all.designerwallcoverings.com. slugify + BANNED are copied VERBATIM from
// all-designerwallcoverings/server.js so hostnames match its vendor slugs exactly.
const ALLDW_PORT = Number(process.env.ALLDW_PORT || 9958);
const slugify = (v) => String(v || '').toLowerCase().normalize('NFD')
  .replace(/[̀-ͯ]/g, '').replace(/&/g, ' and ')
  .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
// Private-label source names all.dw's public rows exclude — those vendors are served by
// the generic *_catalog viewer instead (their products surface on all.dw under the label).
const BANNED = /brewster|york|wallquest|wall\s*quest|newwall|new\s*wall\b|command\s*54|justin\s*david|nicolette\s*mayer|seabrook|chesapeake|nextwall|desima|carlsten/i;

let VENDOR_MAP = new Map();   // slug -> { names: [vendor strings], count }
let vendorMapAt = 0;
async function refreshVendorMap() {
  if (Date.now() - vendorMapAt < 15 * 60 * 1000 && VENDOR_MAP.size) return;
  // Per vendor: LIVE count (what all.dw shows by default) and TOTAL count. all.dw
  // hides Archived + DELETED_FROM_SHOPIFY, so a vendor with live=0 but total>0 is
  // archived-only — we still give it an all.dw site, just landing on the Archived
  // lifecycle so the grid isn't empty (see routing).
  const { rows } = await pool.query(
    `select vendor,
            count(*) filter (where upper(coalesce(status,'')) not in ('ARCHIVED','DELETED_FROM_SHOPIFY'))::int as live,
            count(*)::int as total
     from shopify_products
     where vendor is not null and vendor <> ''
     group by vendor`);
  const m = new Map();
  for (const r of rows) {
    if (BANNED.test(r.vendor)) continue;
    const slug = slugify(r.vendor);
    if (!slug) continue;
    const e = m.get(slug) || { names: [], live: 0, total: 0 };
    e.names.push(r.vendor); e.live += r.live; e.total += r.total;
    m.set(slug, e);
  }
  VENDOR_MAP = m; vendorMapAt = Date.now();
}

// ---------- column auto-detection over heterogeneous *_catalog tables ----------
const CANDIDATES = {
  title:   ['pattern_name', 'product_name', 'title', 'name', 'pattern', 'design_name', 'style_name'],
  sku:     ['sku', 'dw_sku', 'mfr_sku', 'item_number', 'product_code', 'sku_code', 'pattern_number', 'handle'],
  image:   ['image_url', 'image', 'img_url', 'image_src', 'thumbnail', 'thumbnail_url', 'main_image', 'primary_image'],
  price:   ['dw_sell_price', 'sell_price', 'retail_price', 'map_price', 'price', 'msrp'],
  cost:    ['cost_price', 'whls_cost', 'cost', 'wholesale_price'],
  color:   ['colorway', 'color', 'colour', 'color_name'],
  created: ['created_at', 'scraped_at', 'imported_at', 'inserted_at', 'updated_at'],
};

const tableCache = new Map(); // slug -> {table, cols:{...}} | null
async function resolveTable(slug) {
  if (tableCache.has(slug)) return tableCache.get(slug);
  const table = slug.replace(/-/g, '_') + '_catalog';
  if (!/^[a-z0-9_]+$/.test(table)) return null;
  const { rows } = await pool.query(
    `select column_name, data_type from information_schema.columns
     where table_schema='public' and table_name=$1`, [table]);
  let info = null;
  if (rows.length) {
    const names = rows.map(r => r.column_name);
    const cols = {};
    for (const [role, cands] of Object.entries(CANDIDATES)) {
      cols[role] = cands.find(c => names.includes(c)) || null;
    }
    // Exact staging row count — used by the dispatcher to decide whether this
    // catalog dwarfs the vendor's live-shopify subset (see the 3x rule below).
    // table is regex-validated ^[a-z0-9_]+$ above, so safe to interpolate; count
    // is cached with `info` for the process lifetime (same staleness as cols).
    const cnt = await pool.query(`select count(*)::int as n from "${table}"`);
    info = { table, cols, rows: cnt.rows[0].n };
  }
  tableCache.set(slug, info);
  return info;
}

const q = (id) => '"' + id.replace(/"/g, '""') + '"';

async function apiProducts(info, url) {
  const { table, cols } = info;
  const page = Math.max(0, parseInt(url.searchParams.get('page') || '0', 10) || 0);
  const limit = 120;
  const sort = url.searchParams.get('sort') || 'newest';
  const search = (url.searchParams.get('q') || '').trim();

  const sel = [];
  for (const [role, col] of Object.entries(cols)) if (col) sel.push(`${q(col)} as ${role}`);
  if (!sel.length) sel.push('*');

  const params = [];
  let where = '';
  if (search) {
    const like = [];
    for (const role of ['title', 'sku', 'color']) {
      if (cols[role]) { params.push('%' + search + '%'); like.push(`${q(cols[role])}::text ilike $${params.length}`); }
    }
    if (like.length) where = 'where ' + like.join(' or ');
  }

  let order = '';
  if (sort === 'newest' && cols.created) order = `order by ${q(cols.created)} desc nulls last`;
  else if (sort === 'sku' && cols.sku) order = `order by ${q(cols.sku)} asc`;
  else if (sort === 'title' && cols.title) order = `order by ${q(cols.title)} asc`;
  else if (sort === 'price_asc' && cols.price) order = `order by ${q(cols.price)} asc nulls last`;
  else if (sort === 'price_desc' && cols.price) order = `order by ${q(cols.price)} desc nulls last`;
  else if (cols.created) order = `order by ${q(cols.created)} desc nulls last`;

  const sql = `select ${sel.join(', ')} from ${q(table)} ${where} ${order} limit ${limit} offset ${page * limit}`;
  const { rows } = await pool.query(sql, params);
  const { rows: cnt } = await pool.query(`select count(*)::int as n from ${q(table)} ${where}`, params);
  return { total: cnt[0].n, page, limit, hasPrice: !!cols.price, hasCreated: !!cols.created, products: rows };
}

// ---------- HTML ----------
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }

function viewerPage(slug, info, total) {
  const name = slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
  return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(name)} — internal.dw</title>
<style>
:root{--min:220px}
body{font-family:-apple-system,Helvetica,Arial,sans-serif;margin:0;background:#f5f4f1;color:#1e2a3a}
header{background:#1e2a3a;color:#fff;padding:14px 22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
header h1{font-size:17px;margin:0;font-weight:600}header a{color:#9fc2e8;text-decoration:none;font-size:13px}
header .count{opacity:.7;font-size:13px}
.controls{display:flex;gap:14px;align-items:center;padding:12px 22px;background:#fff;border-bottom:1px solid #e2ded7;position:sticky;top:0;z-index:5;flex-wrap:wrap}
.controls label{font-size:12px;color:#666;display:flex;align-items:center;gap:6px}
select,input[type=search]{padding:6px 8px;border:1px solid #ccc;border-radius:6px;font-size:13px}
input[type=range]{width:140px}
#grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--min),1fr));gap:14px;padding:18px 22px}
.card{background:#fff;border:1px solid #e2ded7;border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.card img{width:100%;aspect-ratio:1;object-fit:cover;background:#eee;display:block}
.card .noimg{width:100%;aspect-ratio:1;background:repeating-linear-gradient(45deg,#eee,#eee 12px,#f7f7f7 12px,#f7f7f7 24px)}
.card .body{padding:10px 12px;font-size:13px;display:flex;flex-direction:column;gap:4px}
.card .t{font-weight:600;line-height:1.25}
.card .sku{color:#777;font-size:12px}
.card .price{color:#1e2a3a;font-weight:600}
.card .when{color:#8a8477;font-size:11px}
#more{display:block;margin:10px auto 40px;padding:10px 26px;font-size:14px;border:1px solid #1e2a3a;background:#fff;border-radius:8px;cursor:pointer}
</style></head><body>
<header><h1>${esc(name)}</h1><span class="count" id="count">${total.toLocaleString()} products</span><a href="//internal.dw${PORT === 80 ? '' : ':' + PORT}/">← all vendors</a></header>
<div class="controls">
  <label>Sort <select id="sort">
    <option value="newest">Newest</option>
    <option value="sku">SKU A→Z</option>
    <option value="title">Title A→Z</option>
    <option value="price_asc">Price ↑</option>
    <option value="price_desc">Price ↓</option>
  </select></label>
  <label>Density <input type="range" id="density" min="120" max="420" step="20"></label>
  <label>Search <input type="search" id="q" placeholder="pattern, sku, color…"></label>
</div>
<div id="grid"></div>
<button id="more" hidden>Load more</button>
<script>
const LS='igw:'+location.host;
const sortEl=document.getElementById('sort'),denEl=document.getElementById('density'),qEl=document.getElementById('q');
const grid=document.getElementById('grid'),more=document.getElementById('more'),count=document.getElementById('count');
sortEl.value=localStorage.getItem(LS+':sort')||'newest';
denEl.value=localStorage.getItem(LS+':density')||'220';
document.documentElement.style.setProperty('--min',denEl.value+'px');
let page=0,busy=false,total=0;
const fmt=iso=>{if(!iso)return'';const d=new Date(iso);return isNaN(d)?'':d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})};
async function load(reset){
  if(busy)return;busy=true;
  if(reset){page=0;grid.innerHTML='';}
  const r=await fetch('/api/products?sort='+sortEl.value+'&page='+page+'&q='+encodeURIComponent(qEl.value));
  const d=await r.json();total=d.total;
  count.textContent=total.toLocaleString()+' products';
  for(const p of d.products){
    const c=document.createElement('div');c.className='card';
    c.innerHTML=(p.image?'<img loading="lazy" src="'+p.image.replace(/"/g,'&quot;')+'">':'<div class="noimg"></div>')+
      '<div class="body"><div class="t"></div><div class="sku"></div>'+
      (p.price!=null?'<div class="price">$'+Number(p.price).toFixed(2)+'</div>':'')+
      (p.created?'<div class="when" title="'+p.created+'">🕓 '+fmt(p.created)+'</div>':'')+'</div>';
    c.querySelector('.t').textContent=p.title||'(untitled)';
    c.querySelector('.sku').textContent=[p.sku,p.color].filter(Boolean).join(' · ');
    grid.appendChild(c);
  }
  page++;more.hidden=grid.children.length>=total;busy=false;
}
sortEl.onchange=()=>{localStorage.setItem(LS+':sort',sortEl.value);load(true)};
denEl.oninput=()=>{localStorage.setItem(LS+':density',denEl.value);document.documentElement.style.setProperty('--min',denEl.value+'px')};
let t;qEl.oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300)};
more.onclick=()=>load(false);
load(true);
</script></body></html>`;
}

// Health probe for the "Re-check all sites" control on the landing page. Hits a
// local port's /healthz (any answer works) — anything that responds with a status
// < 500, incl. a 401 auth gate, is "up". This is a re-CHECK, not a restart: it only
// re-tests reachability, so it's safe to click anytime.
function probePort(port) {
  return new Promise((resolve) => {
    const started = Date.now();
    const rq = http.request({ host: '127.0.0.1', port, method: 'GET', path: '/healthz', timeout: 4000 }, (r) => {
      r.resume();
      resolve({ up: r.statusCode > 0 && r.statusCode < 500, status: r.statusCode, ms: Date.now() - started });
    });
    rq.on('timeout', () => { rq.destroy(); resolve({ up: false, status: 0, ms: Date.now() - started }); });
    rq.on('error', () => resolve({ up: false, status: 0, ms: Date.now() - started }));
    rq.end();
  });
}

async function indexPage() {
  await refreshVendorMap();
  const { rows } = await pool.query(`
    select t.table_name, coalesce(c.reltuples,0)::bigint as est
    from information_schema.tables t
    left join pg_class c on c.relname = t.table_name and c.relkind='r'
    where t.table_schema='public' and t.table_name like '%\\_catalog'
    order by t.table_name`);
  const suffix = PORT === 80 ? '' : ':' + PORT;
  const catalogSlugs = new Set(rows.map(r => r.table_name.replace(/_catalog$/, '').replace(/_/g, '-')));

  const li = (slug, badge, n, port) =>
    `<li><span class="dot"${port ? ` data-port="${port}"` : ' data-alldw="1"'}></span>` +
    `<a href="//${slug}.internal.dw${suffix}/">${esc(slug)}.internal.dw</a>` +
    (n != null ? ` <span class="n">${Number(n).toLocaleString()}</span>` : '') + ` ${badge}</li>`;

  const catalogSet = catalogSlugs;
  const liveVendor = (slug) => { const v = VENDOR_MAP.get(slug); return v && v.live > 0; };

  // Group 1 — bespoke dedicated viewers.
  const dedicated = Object.entries(ROUTES)
    .sort((a, b) => a[0].localeCompare(b[0]))
    .map(([slug, port]) => li(slug, `<span class="ded">dedicated :${port}</span>`, null, port));

  // Group 2 — all.dw-schema vendors with LIVE shopify rows (not dedicated).
  const alldw = [...VENDOR_MAP.entries()]
    .filter(([slug, e]) => !ROUTES[slug] && e.live > 0)
    .sort((a, b) => a[0].localeCompare(b[0]))
    .map(([slug, e]) => li(slug, `<span class="all">all.dw</span>`, e.live, null));

  // Group 3 — catalog-backed lines (a *_catalog table, not dedicated, not a live vendor).
  const catalogOnly = rows
    .map(r => [r.table_name.replace(/_catalog$/, '').replace(/_/g, '-'), Number(r.est)])
    .filter(([slug]) => !ROUTES[slug] && !liveVendor(slug))
    .map(([slug, est]) => li(slug, `<span class="cat">catalog</span>`, est, null));

  // Group 4 — archived-only shopify vendors (no live rows, no catalog table) → all.dw Archived view.
  const archived = [...VENDOR_MAP.entries()]
    .filter(([slug, e]) => !ROUTES[slug] && e.live === 0 && e.total > 0 && !catalogSet.has(slug))
    .sort((a, b) => a[0].localeCompare(b[0]))
    .map(([slug, e]) => li(slug, `<span class="arc">archived</span>`, e.total, null));

  const total = dedicated.length + alldw.length + catalogOnly.length + archived.length;
  const section = (title, arr) => arr.length
    ? `<h2>${title} <span class="cnt">${arr.length}</span></h2><ul>${arr.join('')}</ul>` : '';

  return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>internal.dw — ${total} vendor sites</title>
<style>body{font-family:-apple-system,Helvetica,Arial,sans-serif;background:#f5f4f1;color:#1e2a3a;margin:0}
header{background:#1e2a3a;color:#fff;padding:16px 24px}h1{font-size:18px;margin:0}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:#555;margin:22px 24px 4px;font-weight:600}
h2 .cnt{color:#aaa;font-weight:400}
ul{columns:3;list-style:none;padding:6px 24px 4px;margin:0;font-size:14px;line-height:1.85}
a{color:#1e2a3a;text-decoration:none}a:hover{text-decoration:underline}
.n{color:#999;font-size:12px}
.ded{color:#2e7d32;font-size:10px;border:1px solid #2e7d32;border-radius:4px;padding:0 4px}
.all{color:#1e4b8a;font-size:10px;border:1px solid #1e4b8a;border-radius:4px;padding:0 4px}
.cat{color:#8a6d1e;font-size:10px;border:1px solid #8a6d1e;border-radius:4px;padding:0 4px}
.arc{color:#777;font-size:10px;border:1px solid #999;border-radius:4px;padding:0 4px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#c3c3c3;margin-right:7px;vertical-align:middle}
.dot.up{background:#2e7d32}.dot.down{background:#c0392b}
#recheck{margin-top:10px;background:#2c3e52;color:#fff;border:1px solid #46617d;border-radius:6px;padding:6px 13px;font-size:13px;cursor:pointer}
#recheck:hover:not(:disabled){background:#3a516b}#recheck:disabled{opacity:.6;cursor:progress}
#lastcheck{color:#cbd6e2;font-size:12px;margin-top:7px}
@media(max-width:900px){ul{columns:2}}@media(max-width:600px){ul{columns:1}}</style></head>
<body><header><h1>internal.dw — ${total} vendor internal sites</h1>
<button id="recheck" title="Re-run the health check on every internal site (no restart — just re-probes reachability)">↻ Re-check all sites</button>
<div id="lastcheck"></div></header>
${section('Dedicated viewers', dedicated)}
${section('all.dw schema · shopify vendors', alldw)}
${section('Catalog-backed lines', catalogOnly)}
${section('Archived lines', archived)}
<script>
const rc=document.getElementById('recheck'),lc=document.getElementById('lastcheck');
let busy=false;
// Re-CHECK (not restart): re-probe every internal site and refresh the dots.
// Dedicated viewers get their own dot (own port); all.dw/catalog/archived rows
// share the single all.dw backend probe.
async function loadStatus(){
  if(busy)return;busy=true;rc.disabled=true;rc.textContent='↻ Checking…';
  try{
    const r=await fetch('/api/status');if(!r.ok)throw new Error('HTTP '+r.status);
    const d=await r.json();let up=0,tot=0;
    document.querySelectorAll('.dot').forEach(el=>{
      tot++;
      const st=el.dataset.port?d.dedicated[el.dataset.port]:d.alldw;
      const ok=!!(st&&st.up);
      el.className='dot '+(ok?'up':'down');
      if(ok)up++;
    });
    lc.textContent='checked '+up+'/'+tot+' up · '+new Date().toLocaleTimeString([],{hour:'numeric',minute:'2-digit',second:'2-digit'});
  }catch(e){ lc.textContent='check failed — click ↻ to retry'; }
  finally{ busy=false;rc.disabled=false;rc.textContent='↻ Re-check all sites'; }
}
rc.onclick=loadStatus;
loadStatus();
</script>
</body></html>`;
}

// ---------- proxy ----------
// Plain pass-through to a dedicated viewer port.
function proxy(req, res, port) {
  const opts = {
    host: '127.0.0.1', port, method: req.method, path: req.url,
    headers: { ...req.headers, host: '127.0.0.1:' + port },
  };
  const up = http.request(opts, (ur) => { res.writeHead(ur.statusCode, ur.headers); ur.pipe(res); });
  up.on('error', () => { res.writeHead(502, { 'content-type': 'text/plain' }); res.end('upstream on :' + port + ' is down'); });
  req.pipe(up);
}

// Vendor-locked proxy to the local all.dw instance. `names` = exact vendor
// string(s) from shopify_products.vendor. We FORCE ?vendors=<names> on the
// /api/products + /api/facets calls (overriding whatever the SPA sent), so the
// grid can only ever show this vendor — no client cooperation, no localStorage
// dependency. The root HTML gets a small injected banner + the (now inert)
// vendor facet row hidden.
function proxyAllDw(req, res, slug, names, count, opts = {}) {
  const url = new URL(req.url, 'http://x');
  const isApi = url.pathname === '/api/products' || url.pathname === '/api/facets';
  if (isApi) {
    url.searchParams.set('vendors', names.join(','));
    // Archived-only vendor: force the Archived lifecycle so the grid isn't empty.
    if (opts.lifecycles) url.searchParams.set('lifecycles', opts.lifecycles);
  }
  const path = isApi ? url.pathname + '?' + url.searchParams.toString() : req.url;

  const label = names[0] || slug;
  const isHtml = url.pathname === '/' || url.pathname === '/index.html';

  const reqOpts = {
    host: '127.0.0.1', port: ALLDW_PORT, method: req.method, path,
    headers: { ...req.headers, host: '127.0.0.1:' + ALLDW_PORT,
      // gzip would defeat the HTML injection; ask upstream for identity on the doc.
      ...(isHtml ? { 'accept-encoding': 'identity' } : {}) },
  };

  const up = http.request(reqOpts, (ur) => {
    const ctype = ur.headers['content-type'] || '';
    if (isHtml && /text\/html/i.test(ctype) && ur.statusCode === 200) {
      const chunks = [];
      ur.on('data', (c) => chunks.push(c));
      ur.on('end', () => {
        let html = Buffer.concat(chunks).toString('utf8');
        const archNote = opts.lifecycles ? ' · archived line' : '';
        const seedLc = opts.lifecycles
          ? `localStorage.setItem('all_lifecycles',JSON.stringify([${JSON.stringify(opts.lifecycles)}]));` : '';
        const inject = `<style>details.sec[data-k="vendor"]{display:none!important}</style>` +
          `<script>try{localStorage.setItem('all_vendors',JSON.stringify(${JSON.stringify(names)}));${seedLc}` +
          `document.title=${JSON.stringify(label + ' — internal.dw')};}catch(e){}</script>` +
          `<div style="position:fixed;left:0;right:0;bottom:0;z-index:9999;background:#1e2a3a;color:#fff;` +
          `font:600 12px -apple-system,Helvetica,Arial;padding:5px 14px;display:flex;gap:12px;align-items:center">` +
          `<span>🏷️ ${label.replace(/</g, '&lt;')} · internal.dw</span>` +
          `<span style="opacity:.65;font-weight:400">${(count || 0).toLocaleString()} products · all.dw schema${archNote}</span>` +
          `<a href="//internal.dw:${PORT}/" style="color:#9fc2e8;text-decoration:none;margin-left:auto">← all vendors</a></div>`;
        html = html.includes('</body>') ? html.replace('</body>', inject + '</body>') : html + inject;
        const headers = { ...ur.headers, 'content-length': Buffer.byteLength(html) };
        delete headers['content-encoding'];
        res.writeHead(ur.statusCode, headers);
        res.end(html);
      });
      return;
    }
    res.writeHead(ur.statusCode, ur.headers);
    ur.pipe(res);
  });
  up.on('error', () => {
    res.writeHead(502, { 'content-type': 'text/plain' });
    res.end('all.dw (all-dw-local :' + ALLDW_PORT + ') is down');
  });
  req.pipe(up);
}

// ---------- server ----------
const server = http.createServer(async (req, res) => {
  try {
    if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }

    if (req.headers.authorization !== AUTH) {
      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="internal.dw"' });
      return res.end('auth required');
    }

    const host = (req.headers.host || '').split(':')[0].toLowerCase();
    const m = host.match(/^(?:([a-z0-9-]+)\.)?internal\.dw$/);
    const slug = m ? m[1] : null;

    if (!m || !slug || slug === 'www') {
      const u = new URL(req.url, 'http://x');
      // Fleet-wide re-check: probe every dedicated viewer port + the shared all.dw
      // backend. Non-dedicated sites (all.dw/catalog/archived) share the :9958 fate.
      if (u.pathname === '/api/status') {
        const dedicated = {};
        await Promise.all(Object.entries(ROUTES).map(async ([, port]) => { dedicated[port] = await probePort(port); }));
        const alldw = await probePort(ALLDW_PORT);
        res.writeHead(200, { 'content-type': 'application/json' });
        return res.end(JSON.stringify({ dedicated, alldw }));
      }
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
      return res.end(await indexPage());
    }

    // 0. Alias slugs → 302 to another internal host (same gateway port), pre-filtered.
    //    e.g. lilycolor → japan.internal.dw/?src_label=Lilycolor (its data + images live
    //    in the shared japan-viewer; a standalone lilycolor_catalog would be imageless).
    if (REDIRECTS[slug]) {
      const [thost, tquery = ''] = REDIRECTS[slug].split('/?');
      const portSuffix = PORT === 80 ? '' : ':' + PORT;
      const location = '//' + thost + portSuffix + '/' + (tquery ? '?' + tquery : '');
      res.writeHead(302, { Location: location });
      return res.end();
    }

    // 1. Bespoke dedicated viewers (luxury microsite lines etc.) keep their own app.
    if (ROUTES[slug]) return proxy(req, res, ROUTES[slug]);

    await refreshVendorMap();
    const vinfo = VENDOR_MAP.get(slug);
    const info = await resolveTable(slug);

    // A staging catalog "dwarfs" the live-shopify subset when it has >=3x the rows.
    // That's the signal only a fraction of a scraped line was onboarded, so the
    // internal curation viewer should show the full staging table, not the tiny live
    // slice (e.g. malibu 3 live vs 3,718 staged). The 3x guard (DTD verdict B,
    // 2026-07-23) keeps mainline vendors whose live set IS the real catalog on the
    // live view (kravet 1.7x, thibaut 1.2x) and never flips a vendor whose staging
    // table is smaller/staler than live (fentucci 118 vs 3,479; phillipe_romano
    // 3,503 vs 24,173) — those would regress under a naive staging-first rule.
    const catalogDwarfsLive = !!info && info.rows >= 3 * ((vinfo && vinfo.live) || 0);

    // 2. Vendor with LIVE shopify rows → all.dw schema/UI, pre-filtered. The standard —
    //    UNLESS a staging catalog dwarfs it, in which case fall through to that catalog.
    if (vinfo && vinfo.live > 0 && !catalogDwarfsLive) return proxyAllDw(req, res, slug, vinfo.names, vinfo.live);

    // 3. A *_catalog table (scraped/live lines, incl. vendors whose shopify line is
    //    fully archived like Arte, OR whose staging dwarfs live) → generic catalog viewer.
    if (info) {
      // fall through to the generic viewer below
    } else if (vinfo && vinfo.total > 0) {
      // 4. Archived-only vendor with no catalog table → all.dw locked to its
      //    Archived lifecycle so the grid still shows its (archived) products.
      return proxyAllDw(req, res, slug, vinfo.names, vinfo.total, { lifecycles: 'Archived' });
    } else {
      res.writeHead(404, { 'content-type': 'text/plain' });
      return res.end(`no internal site for "${slug}": no dedicated viewer, no shopify vendor, no ${slug.replace(/-/g, '_')}_catalog table`);
    }

    const url = new URL(req.url, 'http://x');
    if (url.pathname === '/api/products') {
      const data = await apiProducts(info, url);
      res.writeHead(200, { 'content-type': 'application/json' });
      return res.end(JSON.stringify(data));
    }
    const { rows } = await pool.query(`select count(*)::int as n from ${q(info.table)}`);
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    return res.end(viewerPage(slug, info, rows[0].n));
  } catch (e) {
    res.writeHead(500, { 'content-type': 'text/plain' });
    res.end('gateway error: ' + e.message);
  }
});

server.listen(PORT, BIND, () => console.log(`dw-internal-gateway on http://${BIND}:${PORT}`));