[object Object]

← back to Designer Wallcoverings

TK-11422 + TK-11418: scope VCC's shared-catalog queries to the requesting vendor

cfff3faae0fe065182d54f208acbe7ac372fd480 · 2026-09-10 12:29:37 -0700 · Steve Abrams

vendor_registry.catalog_table resolves to the SHARED `vendor_catalog` for 29 vendors,
and that table holds rows for 157 distinct vendor_codes. Two endpoints queried it with no
vendor filter:

TK-11422 assign-skus (worse, and nothing was blocking it) — the SELECT of rows missing a
dw_sku was unfiltered, so one authenticated click enumerated every unassigned row in the
whole shared table and stamped each with the REQUESTING vendor's prefix. Measured
2026-09-10: 3,132 rows with an empty dw_sku spanning 31 different vendors. The
`UPDATE ... WHERE id = $2` after it looked scoped but was only ever as scoped as that
SELECT. Simulated after the fix: 3,132 -> each vendor's own rows only (7, 6, 2, 1, 1, 0...).

TK-11418 launch-test — `ORDER BY id DESC LIMIT 1` returned the newest row in the entire
shared table regardless of which vendor the operator selected (today the top 100 rows are
all `justindavid`), and the endpoint then created and activated it on live Shopify under
the selected vendor. Forensics found no evidence it ever fired; it is currently inert only
because Silas (127.0.0.1:9674) is down and STEP 2 short-circuits before the Shopify write.
That is safety by accident — reviving Silas would have armed it.

Both fixes mirror the pattern already in this file at ~:2255 (resolveSharedDiscriminator +
vendorFilter), added to fix the identical hazard in syncVendorCounts and never propagated.
Both FAIL CLOSED: a shared table with no vendor_code/brand/vendor column returns 409 /
skips the vendor rather than operating across vendors.

NOT fixed here (classifier-blocked, tracked on TK-11418): STEP 8 still stamps
`WHERE dw_sku = $2`, which is ambiguous — 1,148 dw_sku values are held by more than one
vendor_code. It needs `id` added to selectCols and the UPDATE scoped by primary key.

Code only. The running pm2 process still has the old code until it is restarted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit cfff3faae0fe065182d54f208acbe7ac372fd480
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 12:29:37 2026 -0700

    TK-11422 + TK-11418: scope VCC's shared-catalog queries to the requesting vendor
    
    vendor_registry.catalog_table resolves to the SHARED `vendor_catalog` for 29 vendors,
    and that table holds rows for 157 distinct vendor_codes. Two endpoints queried it with no
    vendor filter:
    
    TK-11422 assign-skus (worse, and nothing was blocking it) — the SELECT of rows missing a
    dw_sku was unfiltered, so one authenticated click enumerated every unassigned row in the
    whole shared table and stamped each with the REQUESTING vendor's prefix. Measured
    2026-09-10: 3,132 rows with an empty dw_sku spanning 31 different vendors. The
    `UPDATE ... WHERE id = $2` after it looked scoped but was only ever as scoped as that
    SELECT. Simulated after the fix: 3,132 -> each vendor's own rows only (7, 6, 2, 1, 1, 0...).
    
    TK-11418 launch-test — `ORDER BY id DESC LIMIT 1` returned the newest row in the entire
    shared table regardless of which vendor the operator selected (today the top 100 rows are
    all `justindavid`), and the endpoint then created and activated it on live Shopify under
    the selected vendor. Forensics found no evidence it ever fired; it is currently inert only
    because Silas (127.0.0.1:9674) is down and STEP 2 short-circuits before the Shopify write.
    That is safety by accident — reviving Silas would have armed it.
    
    Both fixes mirror the pattern already in this file at ~:2255 (resolveSharedDiscriminator +
    vendorFilter), added to fix the identical hazard in syncVendorCounts and never propagated.
    Both FAIL CLOSED: a shared table with no vendor_code/brand/vendor column returns 409 /
    skips the vendor rather than operating across vendors.
    
    NOT fixed here (classifier-blocked, tracked on TK-11418): STEP 8 still stamps
    `WHERE dw_sku = $2`, which is ambiguous — 1,148 dw_sku values are held by more than one
    vendor_code. It needs `id` added to selectCols and the UPDATE scoped by primary key.
    
    Code only. The running pm2 process still has the old code until it is restarted.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 DW-Agents/vendor-command-center/server.js | 69 ++++++++++++++++++++++++++++++-
 1 file changed, 67 insertions(+), 2 deletions(-)

diff --git a/DW-Agents/vendor-command-center/server.js b/DW-Agents/vendor-command-center/server.js
index 5202e540..77933003 100644
--- a/DW-Agents/vendor-command-center/server.js
+++ b/DW-Agents/vendor-command-center/server.js
@@ -909,9 +909,46 @@ app.post('/api/vendors/:code/assign-skus', async (req, res) => {
     let nextNum = Math.max(maxCat, maxShop, rangeStart);
     if (nextNum < rangeStart) nextNum = rangeStart;
 
+    // TK-11422: this SELECT MUST be scoped to THIS vendor's rows. catalog_table is the
+    // SHARED `vendor_catalog` for 29 registered vendors, and that table holds rows for 157
+    // distinct vendor_codes. Unfiltered, one call to this endpoint enumerated every
+    // unassigned row in the whole shared table and stamped each with the REQUESTING
+    // vendor's prefix — measured 2026-09-10: 3,132 rows with an empty dw_sku spanning 31
+    // different vendors. The `UPDATE ... WHERE id = $2` below looks scoped but is only ever
+    // as scoped as this query. Same hazard, same fix as syncVendorCounts (~:2255
+    // resolveSharedDiscriminator + vendorFilter) — that fix was never propagated here.
+    let vendorAnd = '';
+    {
+      const { rows: sharers } = await pool.query(
+        'SELECT COUNT(*)::int AS n FROM vendor_registry WHERE catalog_table = $1',
+        [v.catalog_table]
+      );
+      if ((sharers[0]?.n || 0) > 1) {
+        const { rows: discRows } = await pool.query(
+          "SELECT column_name FROM information_schema.columns WHERE table_name=$1 AND column_name IN ('vendor_code','brand','vendor')",
+          [catTable]
+        );
+        const have = new Set(discRows.map(r => r.column_name));
+        const discCol = have.has('vendor_code') ? 'vendor_code'
+          : have.has('brand') ? 'brand'
+          : have.has('vendor') ? 'vendor' : null;
+        // Fail CLOSED. A shared table with no discriminator cannot be scoped, and minting
+        // one vendor's prefix across other vendors' rows is worse than doing nothing.
+        if (!discCol) {
+          return res.status(409).json({
+            success: false,
+            error: `${catTable} is shared by ${sharers[0].n} vendors and has no vendor_code/brand/vendor column to scope by — refusing to assign SKUs across vendors.`
+          });
+        }
+        const discVal = String((discCol === 'vendor_code' ? v.vendor_code : v.vendor_name) || '').replace(/'/g, "''");
+        // Case-fold both sides: catalog rows carry mixed-case codes (dwdp vs DWDP, grd vs GRD).
+        vendorAnd = ` AND LOWER(${discCol}) = LOWER('${discVal}')`;
+      }
+    }
+
     // Get products missing dw_sku, ordered by mfr_sku for consistency
     const missing = await pool.query(
-      `SELECT id, mfr_sku FROM ${catTable} WHERE dw_sku IS NULL OR dw_sku = '' ORDER BY mfr_sku ASC`
+      `SELECT id, mfr_sku FROM ${catTable} WHERE (dw_sku IS NULL OR dw_sku = '')${vendorAnd} ORDER BY mfr_sku ASC`
     );
 
     if (missing.rows.length === 0) {
@@ -1066,6 +1103,34 @@ app.post('/api/launch-test', async (req, res) => {
       }
 
       // ── STEP 1: Get newest eligible product ──
+      // TK-11418: scope to THIS vendor. catalog_table is the SHARED `vendor_catalog` for 29
+      // registered vendors, and that table holds rows for 157 distinct vendor_codes. Without
+      // this filter, `ORDER BY id DESC LIMIT 1` returned the newest row in the ENTIRE shared
+      // table regardless of which vendor the operator selected — measured 2026-09-10, the
+      // top 100 rows were all `justindavid` — and STEP 8 then published it to Shopify under
+      // the selected vendor. Fails CLOSED if a shared table has no discriminator column.
+      let vendorAnd = '';
+      {
+        const { rows: sharers } = await pool.query(
+          'SELECT COUNT(*)::int AS n FROM vendor_registry WHERE catalog_table = $1', [v.catalog_table]
+        );
+        if ((sharers[0]?.n || 0) > 1) {
+          const discCol = colSet.has('brand') ? 'brand' : null;
+          const { rows: dRows } = await pool.query(
+            "SELECT column_name FROM information_schema.columns WHERE table_name=$1 AND column_name IN ('vendor_code','brand','vendor')", [catTable]
+          );
+          const have = new Set(dRows.map(r => r.column_name));
+          const useCol = have.has('vendor_code') ? 'vendor_code'
+            : have.has('brand') ? 'brand'
+            : have.has('vendor') ? 'vendor' : discCol;
+          if (!useCol) {
+            results.push({ vendor: v.vendor_name, status: 'error', reason: `${catTable} is shared by ${sharers[0].n} vendors with no discriminator column — refusing to publish another vendor's product` });
+            continue;
+          }
+          const discVal = String((useCol === 'vendor_code' ? v.vendor_code : v.vendor_name) || '').replace(/'/g, "''");
+          vendorAnd = ` AND LOWER(${useCol}) = LOWER('${discVal}')`;
+        }
+      }
       const widthFilter = colSet.has('width') ? "AND width IS NOT NULL AND width <> ''" : '';
       const imageFilter = colSet.has('image_url') ? "AND image_url IS NOT NULL AND image_url <> ''" : '';
       const colorFilter = colSet.has('color_name') ? "AND color_name IS NOT NULL AND color_name <> ''" : '';
@@ -1096,7 +1161,7 @@ app.post('/api/launch-test', async (req, res) => {
       const { rows: products } = await pool.query(
         `SELECT ${selectCols.join(', ')}
          FROM "${catTable}"
-         WHERE dw_sku IS NOT NULL AND shopify_product_id IS NULL
+         WHERE dw_sku IS NOT NULL AND shopify_product_id IS NULL${vendorAnd}
            ${widthFilter} ${imageFilter} ${colorFilter}
          ORDER BY id DESC LIMIT 1`
       );

← a8005d1d auto-data-snapshot: 2026-09-10T11:08:25 (1 data files) — mai  ·  back to Designer Wallcoverings  ·  TK-11400: fix false-success in inventory-set-2026-newest swe e252ac31 →