[object Object]

← back to All Designerwallcoverings

live-stock: vault-cred adapter for Thibaut (creds referenced in place from dw-price-stock/.env)

a35f7880885f171b867bb8c132bb68b4ec5bd3ed · 2026-07-07 13:26:21 -0700 · Steve

- scripts/adapters/creds.js: vault-first cred resolver (dw-price-stock/.env by path via
  DW_PRICE_STOCK_ENV), registry fallback; values never logged/copied
- scripts/adapters/thibaut.js: Thibaut trade-portal live-stock adapter (Shopify storefront,
  #email/#password login), PUBLIC-SAFE availability only, price null
- scripts/adapters/index.js: register thibaut
- scripts/build-coverage.js: VAULT_PORTAL_ADAPTERS map + classify branch (Thibaut only),
  subject to the resolvability gate; placed before the public-Shopify branch (stock is trade-gated)
- docs/live-stock-creds-in-place.md: design note + 8-vendor feasibility + Steve-gated expansion plan

Files touched

Diff

commit a35f7880885f171b867bb8c132bb68b4ec5bd3ed
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 13:26:21 2026 -0700

    live-stock: vault-cred adapter for Thibaut (creds referenced in place from dw-price-stock/.env)
    
    - scripts/adapters/creds.js: vault-first cred resolver (dw-price-stock/.env by path via
      DW_PRICE_STOCK_ENV), registry fallback; values never logged/copied
    - scripts/adapters/thibaut.js: Thibaut trade-portal live-stock adapter (Shopify storefront,
      #email/#password login), PUBLIC-SAFE availability only, price null
    - scripts/adapters/index.js: register thibaut
    - scripts/build-coverage.js: VAULT_PORTAL_ADAPTERS map + classify branch (Thibaut only),
      subject to the resolvability gate; placed before the public-Shopify branch (stock is trade-gated)
    - docs/live-stock-creds-in-place.md: design note + 8-vendor feasibility + Steve-gated expansion plan
---
 docs/live-stock-creds-in-place.md | 107 ++++++++++++++++++++++++++++++++++++++
 scripts/adapters/creds.js         |  66 +++++++++++++++++++++++
 scripts/adapters/index.js         |   1 +
 scripts/adapters/thibaut.js       |  96 ++++++++++++++++++++++++++++++++++
 scripts/build-coverage.js         |  19 +++++++
 5 files changed, 289 insertions(+)

diff --git a/docs/live-stock-creds-in-place.md b/docs/live-stock-creds-in-place.md
new file mode 100644
index 0000000..0864239
--- /dev/null
+++ b/docs/live-stock-creds-in-place.md
@@ -0,0 +1,107 @@
+# Live-stock for login-gated vendors — creds referenced IN PLACE (never copied)
+
+**Status:** Thibaut wired as the ONE proof (2026-07-07). Expansion to the other vault vendors is
+Steve-gated (see "Expansion plan" below).
+
+## The problem
+
+The live-stock coverage generator (`scripts/build-coverage.js`) enables a login-gated vendor only
+when it finds trade creds in `dw_unified.vendor_registry.trade_password`. Several high-value
+login-gated lines (Thibaut, Schumacher, Fabricut, Koroseal, Timorous Beasties, …) have **no creds in
+`vendor_registry`** — their trade credentials live ONLY in the on-host credential vault at
+`/root/DW-Agents/dw-price-stock/.env` (Parker's `.env`). So the grid honestly dims them
+"no trade credentials on file," even though we *can* log in.
+
+We want "Check Live" to work for those vendors **without** copying a single trade cred into this
+repo's `.env`, into `dw_unified`, or onto any public surface.
+
+## What Parker (`dw-price-stock`) actually is — and is NOT
+
+Investigated read-only on prod (2026-07-07). `dw-price-stock` (pm2 name `dw-price-stock`, "Parker —
+DW Price & Stock Checker", port 7205) is a **cost/price aggregator**: its `server.js` routes sync
+costs from Shopify (GraphQL `inventoryItem.unitCost`), import price sheets from Google Drive and
+Gmail attachments, and report MAP compliance / margin alerts. It has **no vendor-portal login code
+and no live-stock scraping** — "trade portal scraping" appears only as recommendation *text*. It was
+**not even running** at investigation time (7205 → `000`).
+
+Consequence: the task's preferred design — "call Parker's existing stock lookup on-host" — **does
+not exist**. Parker is not a stock service; it is simply the **home of the shared credential vault**
+(`.env` holds `<VENDOR>_LOGIN_URL/_USERNAME/_PASSWORD` triples for ~30 vendors plus FTP/Shopify/
+proxy creds). So we take the documented fallback: **an on-host adapter that references the vault by
+path at runtime.**
+
+## The design (creds stay entirely in the vault)
+
+1. **`scripts/adapters/creds.js`** — a resolver that, for a given vendor ENV prefix (e.g. `THIBAUT`),
+   reads `<PREFIX>_USERNAME/_PASSWORD/_LOGIN_URL` from the vault file **by path**
+   (`process.env.DW_PRICE_STOCK_ENV`, default `/root/DW-Agents/dw-price-stock/.env`), parsed with
+   `dotenv.parse` **in memory only**. Values are held only for the duration of one login and are
+   **never** written back, logged, echoed, or copied into any `.env`/DB. Falls back to
+   `vendor_registry` (the existing wallquest/romo pattern) when the vault has no entry.
+   `DW_PRICE_STOCK_ENV` is a **path pointer, not a secret** — safe to place in this repo's `.env`.
+
+2. **Per-vendor login adapter** (`scripts/adapters/<vendor>.js`) — same shape as the existing
+   `wallquest`/`romo` adapters: opens a Browserbase session, logs in using creds from `creds.js`,
+   resolves the SKU's PDP via `resolve.js`, reads availability, and returns a **PUBLIC-SAFE** result
+   (availability + our-retail only; trade/net price is `null` and never emitted).
+
+3. **`build-coverage.js`** gains a small `VAULT_PORTAL_ADAPTERS` map (vendor → wired adapter). A
+   vendor in that map with a dedicated resolvable catalog is classified `PORTAL_CREDS`,
+   `live_capable:true`, still subject to the resolvability gate (`resolvable ≥ 5 && ≥ 20%`). The map
+   is placed **before** the public-Shopify branch because a vault-portal vendor's stock is
+   trade-gated — not exposed on the anonymous storefront.
+
+Nothing about the paid live path, the daily `$` kill-switch, per-IP/burst caps, or the public-safe
+contract changes — this only teaches coverage that "an adapter + vault creds exist."
+
+## Why this keeps creds in place
+
+- The vault file is read by **path**, parsed into process memory, used for one login, discarded.
+- No trade cred is ever written to `all-designerwallcoverings/.env` or `dw_unified`. Only a **path
+  pointer** (`DW_PRICE_STOCK_ENV`) lives in this repo's `.env`.
+- Live output is availability (+ our-retail) only — never cost/net/wholesale.
+
+## Proof vendor — Thibaut
+
+Chosen because it has vault creds, **passes the resolvability gate by the widest margin**, is the
+highest-value of the eight, and its portal login is already proven workable across the fleet
+(lowest single-session anti-bot risk).
+
+Resolvability (prod dw_unified, 2026-07-07):
+
+| Vendor            | active | resolvable | pct | gate |
+|-------------------|-------:|-----------:|----:|------|
+| **Thibaut**       |  2384  | 1619       | 68% | PASS |
+| Schumacher        |  2679  | 1057       | 39% | PASS |
+| Fabricut          |     3  |    1       | 33% | FAIL (tiny) |
+| Koroseal          |  2351  |    9       |  0% | FAIL (SKU↔URL misalign) |
+| Designtex         |   417  |    0       |  0% | FAIL (schema-public, misalign) |
+| Contrado          |    15  |    0       |  0% | FAIL (schema-public, misalign) |
+| Timorous Beasties |     0  |    0       |  0% | FAIL (no active grid rows) |
+| Folia             |    —   |    —       |  —  | not a grid vendor |
+
+Thibaut confirmed a genuine **live-stock** vendor (not price-only): the anonymous PDP is a Shopify
+storefront exposing `availableForSale` / `stocked` / `backorder`, and states outright that
+"stock status … [is] available to registered trade accounts" — i.e. live inventory is behind the
+trade login. Login page: `https://www.thibautdesign.com/account/login` (`#email` + `#password`).
+
+## Per-vendor live-stock feasibility for the eight
+
+- **Live-stock capable + creds in vault:** Thibaut (PASS, wired), Schumacher (PASS, needs adapter).
+- **Creds in vault but not gate-ready:** Fabricut (catalog too small), Koroseal, Designtex, Contrado
+  (display SKUs don't align with catalog `product_url`s — need a SKU remap before any adapter helps),
+  Timorous Beasties (no active grid rows under that name).
+- **Folia** — not sold under a "Folia" display vendor in the grid; nothing to enable.
+
+## Expansion plan (Steve-gated — do NOT wire until approved)
+
+1. **Schumacher** — write `scripts/adapters/schumacher.js` (creds prefix `SCHUMACHER`,
+   `schumacher.com/catalog/products/<mfr_sku>`), add `schumacher` to `VAULT_PORTAL_ADAPTERS`. Gate
+   already PASSES (39%). Effort: ~1–2h + one careful proof session.
+2. **Koroseal / Designtex / Contrado / Fabricut** — the blocker is **SKU↔product_url alignment**, not
+   creds. Effort is a per-vendor catalog remap (rebuild each `_catalog.product_url` keyed to the grid
+   SKUs), then Designtex/Contrado can even use the existing **login-free** `schema-public` adapter;
+   Koroseal/Fabricut would need a login adapter. Effort: ~2–4h per vendor (mostly catalog work).
+3. **Timorous Beasties / Folia** — no grid rows to serve; skip until they exist in the catalog.
+
+Each new portal = ONE careful proof session (anti-bot / trade-account-lock risk), never a mass login.
diff --git a/scripts/adapters/creds.js b/scripts/adapters/creds.js
new file mode 100644
index 0000000..141d09d
--- /dev/null
+++ b/scripts/adapters/creds.js
@@ -0,0 +1,66 @@
+// scripts/adapters/creds.js — resolve a login-gated vendor's trade creds WITHOUT copying them.
+//
+// SECURITY MODEL (see docs/live-stock-creds-in-place.md):
+//   Priority 1 — the dw-price-stock credential VAULT, referenced BY PATH at runtime. The vault file
+//                is read, parsed with dotenv.parse into process memory, used for ONE login, and
+//                discarded. Values are NEVER written back, logged, echoed, or copied into this
+//                repo's .env or dw_unified. DW_PRICE_STOCK_ENV is a PATH POINTER, not a secret.
+//   Priority 2 — dw_unified.vendor_registry (the existing wallquest/romo DB-cred pattern), used only
+//                when the vault has no entry for the vendor.
+//
+// The returned object carries the raw user/pass so the adapter can log in; callers MUST NOT log it.
+const fs = require('fs');
+
+// Path pointer to Parker's vault. Overridable via env; defaults to the on-host location.
+const VAULT_PATH = process.env.DW_PRICE_STOCK_ENV || '/root/DW-Agents/dw-price-stock/.env';
+
+let _vault = null; // parsed once per process
+function vault() {
+  if (_vault) return _vault;
+  _vault = {};
+  try {
+    const txt = fs.readFileSync(VAULT_PATH, 'utf8');
+    // dotenv.parse — reference the vault in place; nothing is ever written back.
+    _vault = require('dotenv').parse(txt);
+  } catch { _vault = {}; }
+  return _vault;
+}
+
+// True iff the vault holds a usable cred pair for this ENV prefix — WITHOUT surfacing any value.
+// (build-coverage can call this to decide enable/dim without ever reading a secret.)
+function vaultHas(prefix) {
+  const v = vault();
+  return !!(v[`${prefix}_USERNAME`] && v[`${prefix}_PASSWORD`]);
+}
+
+// {user,pass,loginUrl,source} from the vault, or null. Never logs values.
+function fromVault(prefix) {
+  const v = vault();
+  const user = v[`${prefix}_USERNAME`];
+  const pass = v[`${prefix}_PASSWORD`];
+  const loginUrl = v[`${prefix}_LOGIN_URL`] || null;
+  if (user && pass) return { user, pass, loginUrl, source: 'vault' };
+  return null;
+}
+
+// Fallback: dw_unified.vendor_registry (same columns wallquest/romo adapters read).
+async function fromRegistry(pool, vendorLike) {
+  if (!pool || !vendorLike) return null;
+  try {
+    const { rows } = await pool.query(
+      `SELECT trade_username, trade_password, login_url FROM vendor_registry
+        WHERE vendor_name ILIKE $1 OR vendor_code ILIKE $1 LIMIT 1`, [vendorLike]);
+    const r = rows[0];
+    if (r && r.trade_password) {
+      return { user: r.trade_username || null, pass: r.trade_password, loginUrl: r.login_url || null, source: 'registry' };
+    }
+  } catch { /* optional */ }
+  return null;
+}
+
+// Vault-first, registry-fallback. { prefix, pool?, vendorLike? } → {user,pass,loginUrl,source}|null.
+async function resolve({ prefix, pool, vendorLike }) {
+  return (prefix && fromVault(prefix)) || (await fromRegistry(pool, vendorLike)) || null;
+}
+
+module.exports = { resolve, fromVault, vaultHas, VAULT_PATH };
diff --git a/scripts/adapters/index.js b/scripts/adapters/index.js
index d637c0d..c9720f9 100644
--- a/scripts/adapters/index.js
+++ b/scripts/adapters/index.js
@@ -14,6 +14,7 @@
 const ADAPTERS = {
   wallquest: require('./wallquest'),
   romo: require('./romo'),
+  thibaut: require('./thibaut'), // trade portal — creds referenced in place from the dw-price-stock vault
   'shopify-public': require('./shopify-public'),
   'woo-public': require('./woo-public'),
   'schema-public': require('./schema-public'), // login-free schema.org/Magento availability
diff --git a/scripts/adapters/thibaut.js b/scripts/adapters/thibaut.js
new file mode 100644
index 0000000..fe08ec6
--- /dev/null
+++ b/scripts/adapters/thibaut.js
@@ -0,0 +1,96 @@
+// adapters/thibaut.js — Thibaut trade-portal live-stock adapter (PORTAL + VAULT CREDS).
+//
+// Thibaut (thibautdesign.com) is a Shopify trade storefront: the anonymous PDP exposes
+// `availableForSale` / `stocked` / `backorder` markup but states outright that live stock status is
+// "available to registered trade accounts" — so real inventory is behind the trade login. Creds are
+// resolved by scripts/adapters/creds.js from the dw-price-stock vault (prefix THIBAUT), referenced
+// IN PLACE — never copied here. They travel only in memory for this one login and are never logged.
+//
+// PUBLIC-SAFE: emits availability only. Thibaut's logged-in price is the trade/net price (no known
+// retail transform in this context), so `price` is ALWAYS null — never emitted.
+const { resolveCatalogUrl, releaseSession } = require('./resolve');
+const creds = require('./creds');
+
+const DEFAULT_LOGIN = 'https://www.thibautdesign.com/account/login';
+
+async function run({ sku, pool, newSession, catalogTable, log }) {
+  let sessionOpened = false, browser = null, session = null, bb = null;
+  try {
+    // 1. Resolve the grid SKU → its Thibaut PDP (bridged via shopify_products → thibaut_catalog).
+    const r = await resolveCatalogUrl(pool, catalogTable || 'thibaut_catalog', sku);
+    if (r.error) return { available: false, reason: r.error };
+    const url = r.product_url;
+
+    // 2. Vault creds (prefix THIBAUT) — referenced in place; registry fallback. NEVER logged.
+    const c = await creds.resolve({ prefix: 'THIBAUT', pool, vendorLike: 'thibaut' });
+    if (!c || !c.pass) return { available: false, reason: 'thibaut trade credentials not available (vault)' };
+    const loginUrl = c.loginUrl || DEFAULT_LOGIN;
+
+    // 3. Metered Browserbase session + Thibaut trade login (#email / #password).
+    const sess = await newSession();
+    ({ browser, session, bb } = sess); const page = sess.page;
+    sessionOpened = true;
+    log('bb session', session && session.id);
+
+    await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
+    await page.waitForTimeout(2500);
+    // dismiss any cookie/consent gate that could overlay the form
+    for (const sel of ['button:has-text("Accept")', 'button:has-text("ACCEPT")', '#onetrust-accept-btn-handler']) {
+      const el = await page.$(sel).catch(() => null);
+      if (el) { await el.click().catch(() => {}); await page.waitForTimeout(600); break; }
+    }
+    const emailEl = await page.$('#email, input[name=email], input[type=email]').catch(() => null);
+    if (emailEl) {
+      await page.fill('#email, input[name=email], input[type=email]', c.user || '').catch(() => {});
+      await page.fill('#password, input[name=password], input[type=password]', c.pass).catch(() => {});
+      await page.waitForTimeout(400);
+      await page.click('button[type=submit], form button:has-text("Sign"), form button:has-text("Log")')
+        .catch(async () => { await page.press('#password, input[type=password]', 'Enter').catch(() => {}); });
+      // wait for the login page to fall away (redirect off /login)
+      for (let i = 0; i < 18; i++) { await page.waitForTimeout(1000); if (!/\/(account\/)?login/i.test(page.url())) break; }
+    }
+    const loggedIn = !/\/(account\/)?login/i.test(page.url());
+
+    // 4. PDP → wait for the trade-gated inventory to hydrate, then read availability.
+    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
+    await page.waitForTimeout(7000);
+
+    const html = await page.content().catch(() => '');
+    // Shopify variant flag + Thibaut's own stock fields (present in the embedded product JSON).
+    const availForSale = /"availableForSale"\s*:\s*true/i.test(html) ? true
+      : /"availableForSale"\s*:\s*false/i.test(html) ? false : null;
+    const stockedM = html.match(/"stocked"\s*:\s*(\d+)/i);
+    const stocked = stockedM ? parseInt(stockedM[1], 10) : null;
+    const backorder = /"backorder"\s*:\s*(1|true)/i.test(html);
+
+    // product-scoped visible status text (avoid global nav "Check Stock"/"Discontinued List" chrome)
+    const availTxt = await page.$$eval(
+      '[itemprop=availability], main [class*=stock], main [class*=availab], main [class*=inventory], button:has-text("Add")',
+      (els) => els.map((e) => (e.getAttribute('content') || e.getAttribute('aria-label') || e.textContent || '').trim()).filter(Boolean).join(' | ')
+    ).catch(() => '');
+
+    let in_stock = null;
+    if (availForSale === false || /out\s*of\s*stock|sold\s*out|discontinued|unavailable/i.test(availTxt)) in_stock = false;
+    else if (availForSale === true || stocked === 1 || /in\s*stock|in-?stock|add to (cart|order|sample)/i.test(availTxt)) in_stock = true;
+    else if (stocked === 0) in_stock = false;
+
+    const label = in_stock === true ? (backorder ? 'in stock (some backorder)' : 'in stock')
+      : in_stock === false ? 'out of stock'
+      : (availTxt ? availTxt.slice(0, 60) : 'unknown (parse pending verification)');
+
+    return {
+      available: true, session_opened: true, vendor: 'Thibaut',
+      in_stock, lead_time: backorder ? 'backorder possible' : null,
+      price: null, // Thibaut logged-in price is trade/net → never emitted
+      raw_stock_label: label,
+      as_of: new Date().toISOString(),
+      source: loggedIn ? 'thibaut-live' : 'thibaut-live (anon fallback)',
+    };
+  } catch (e) {
+    return { available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) };
+  } finally {
+    await releaseSession({ browser, bb, session });
+  }
+}
+
+module.exports = { run };
diff --git a/scripts/build-coverage.js b/scripts/build-coverage.js
index ffeb0dc..e8a4152 100644
--- a/scripts/build-coverage.js
+++ b/scripts/build-coverage.js
@@ -96,6 +96,17 @@ const NO_LIVE_STOCK_PORTAL = new Set([
   'kravet', // Kravet SFTP master = MAP/wholesale price sheet only; no per-SKU live inventory
 ]);
 
+// Login-gated vendors whose trade creds live in the dw-price-stock credential vault (referenced IN
+// PLACE at runtime by scripts/adapters/creds.js — NEVER copied here or into dw_unified) AND whose
+// portal exposes LIVE STOCK via a wired login adapter. Enabling here only asserts "an adapter + vault
+// creds exist"; the resolvability gate (≥5 & ≥20%) still governs. Placed BEFORE the public-Shopify
+// branch in classify() because a vault-portal vendor's stock is trade-gated, not on the anon store.
+// Expansion beyond Thibaut is Steve-gated — see docs/live-stock-creds-in-place.md.
+const VAULT_PORTAL_ADAPTERS = new Map([
+  ['thibaut', { adapter: 'thibaut', catalog_table: 'thibaut_catalog',
+    reason: 'Thibaut trade portal — creds referenced in place from the dw-price-stock vault (live stock behind trade login)' }],
+]);
+
 const QUOTE_ONLY = new Set([
   'dedar', 'de gournay', 'de gournay murals', 'fromental', 'gracie', 'iksel', 'zuber',
   'porter teleo', 'holland and sherry', 'holland & sherry', 'clarence house', 'fortuny',
@@ -204,6 +215,14 @@ async function main() {
     if (isRomo && hasUrls(tbl))
       return { tier: 'PORTAL_CREDS', live_capable: true, adapter: 'romo',
         reason: 'Romo trade portal (creds on file)', catalog_table: tbl || 'romo_catalog' };
+    // 1b. VAULT-PORTAL — trade creds in the dw-price-stock vault (referenced in place), portal
+    //     exposes LIVE stock behind login → wired login adapter. Precedes the public-Shopify branch
+    //     because a vault-portal vendor's stock is trade-gated, not on the anonymous storefront.
+    if (VAULT_PORTAL_ADAPTERS.has(v) && dedicated(tbl) && hasUrls(tbl)) {
+      const m = VAULT_PORTAL_ADAPTERS.get(v);
+      return { tier: 'PORTAL_CREDS', live_capable: true, adapter: m.adapter,
+        reason: m.reason, catalog_table: m.catalog_table || tbl };
+    }
     // 2. PUBLIC_STOCK (Shopify) — canonical public .js feed, dedicated catalog, no login → ENABLE.
     //    Precedes the portal check: a login_url may exist (an account page) yet stock is public.
     if (oneToOne && dedicated(tbl) && hasUrls(tbl) && catShopify.get(tbl))

← 9c2f43a all.dw: dedupe microsite seeds by vendor (fold DB auto-slug  ·  back to All Designerwallcoverings  ·  live-stock: enable Thibaut in coverage (vault-cred proof lan 3340f23 →