[object Object]

← back to Designer Wallcoverings

fix(TK-11503): validate Shopify token via read_products probe, fail loud if none valid

25dadd6f3c8bce7badc9dc7f4d36b3506f94fb6a · 2026-09-14 15:26:18 -0700 · Steve

sync-shopify-products.js selected its admin token with a bare `||`, which picks
the first PRESENT value — so a present-but-revoked token permanently shadowed a
healthy fallback and silently synced 0 products (TK-10930). Replace with
resolveToken(): probe each candidate against the real products(first:1) read
this sync issues (proving read_products scope, not just auth), fall through on
401/invalid, and throw loudly if none validate. Adds a --resolve-only seam that
validates without syncing. Reviewed clean: token is read dynamically per query
and resolveToken() is awaited before any Shopify call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 25dadd6f3c8bce7badc9dc7f4d36b3506f94fb6a
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Sep 14 15:26:18 2026 -0700

    fix(TK-11503): validate Shopify token via read_products probe, fail loud if none valid
    
    sync-shopify-products.js selected its admin token with a bare `||`, which picks
    the first PRESENT value — so a present-but-revoked token permanently shadowed a
    healthy fallback and silently synced 0 products (TK-10930). Replace with
    resolveToken(): probe each candidate against the real products(first:1) read
    this sync issues (proving read_products scope, not just auth), fall through on
    401/invalid, and throw loudly if none validate. Adds a --resolve-only seam that
    validates without syncing. Reviewed clean: token is read dynamically per query
    and resolveToken() is awaited before any Shopify call.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 shopify/scripts/sync-shopify-products.js | 87 +++++++++++++++++++++++++++++---
 1 file changed, 81 insertions(+), 6 deletions(-)

diff --git a/shopify/scripts/sync-shopify-products.js b/shopify/scripts/sync-shopify-products.js
index e5f2af0e..a2ae29d0 100644
--- a/shopify/scripts/sync-shopify-products.js
+++ b/shopify/scripts/sync-shopify-products.js
@@ -34,12 +34,73 @@ const pool = new Pool({
 });
 
 const SHOPIFY_STORE = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
-const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;
+// TK-11503: presence-vs-validity fix. A bare `||` selects the first PRESENT value, so a
+// present-but-revoked token permanently shadows a healthy fallback — which silently broke
+// this launchd sync on 2026-09-11 (TK-10930: SHOPIFY_ADMIN_ACCESS_TOKEN was revoked but
+// still defined in .env, so the ADMIN_TOKEN fallback never fired). Instead we VALIDATE each
+// candidate against the real products-read this sync issues (proving read_products scope,
+// not just auth) and fall through on 401/invalid. Preference: canonical narrow admin token
+// first (least privilege for a read-only sync), then legacy access token, then full-access
+// as last resort. Resolved by resolveToken() at startup — see below.
+const TOKEN_CANDIDATES = [
+  ['SHOPIFY_ADMIN_TOKEN', process.env.SHOPIFY_ADMIN_TOKEN],
+  ['SHOPIFY_ADMIN_ACCESS_TOKEN', process.env.SHOPIFY_ADMIN_ACCESS_TOKEN],
+  ['SHOPIFY_FULL_ACCESS_TOKEN', process.env.SHOPIFY_FULL_ACCESS_TOKEN],
+].filter(([, v]) => v && String(v).trim());
+let SHOPIFY_TOKEN = null; // set by resolveToken() before any Shopify query runs
 // Align the fallback with the two write scripts (houston-mfr-fix / pr-quote-only-tag both
 // default '2024-10'). Only bites when .env lacks SHOPIFY_ADMIN_API_VERSION; keeping three
 // files on one version avoids a silent stale-API drift on the token-less path.
 const API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-10';
 
+// TK-11503: probe ONE candidate token against the exact products read this sync issues,
+// so a 200 proves read_products SCOPE (not merely auth — the TK-10930 lesson: a healthy
+// {shop{name}} would still 200 for a token lacking read_products). Returns 'ok' (usable),
+// 'invalid' (401/403 or a GraphQL auth/scope error → try next candidate), or 'transient'
+// (network / 5xx / parse blip → inconclusive, retry once then skip).
+async function probeToken(token) {
+  let res;
+  try {
+    res = await fetch(
+      `https://${SHOPIFY_STORE}/admin/api/${API_VERSION}/graphql.json`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
+        body: JSON.stringify({ query: '{ products(first: 1) { nodes { id } } }' }),
+      }
+    );
+  } catch (_) { return 'transient'; }
+  if (res.status === 401 || res.status === 403) return 'invalid';
+  if (res.status >= 500) return 'transient';
+  let body;
+  try { body = await res.json(); } catch (_) { return 'transient'; }
+  const msg = (body.errors || []).map(e => e && e.message).join(' ');
+  if (/access denied|not authorized|invalid api key|access token|unauthorized/i.test(msg)) return 'invalid';
+  if (body.data && body.data.products) return 'ok';
+  return 'transient';
+}
+
+// TK-11503: resolve SHOPIFY_TOKEN by VALIDATING candidates in preference order and falling
+// through on invalid. Fail LOUD if none validate — never exit 0 token-less (guards the
+// 2026-07-12 silent "Synced 0" bug). Must be awaited before syncProducts runs.
+async function resolveToken() {
+  if (!TOKEN_CANDIDATES.length) {
+    throw new Error('TK-11503: no Shopify admin token present in env — cannot sync.');
+  }
+  const tried = [];
+  for (const [name, token] of TOKEN_CANDIDATES) {
+    let verdict = await probeToken(token);
+    if (verdict === 'transient') verdict = await probeToken(token); // one retry for a blip
+    tried.push(`${name}=${verdict}`);
+    if (verdict === 'ok') {
+      SHOPIFY_TOKEN = token;
+      console.log(`🔑 [token] using ${name} (validated read_products)`);
+      return token;
+    }
+  }
+  throw new Error(`TK-11503: no Shopify token validated for read_products. Tried: ${tried.join(', ')}`);
+}
+
 async function shopifyGraphQL(query, variables = {}) {
   const response = await fetch(
     `https://${SHOPIFY_STORE}/admin/api/${API_VERSION}/graphql.json`,
@@ -760,11 +821,25 @@ async function skuExists(sku) {
   return result.rows.length > 0;
 }
 
-// Run sync
+// Run sync — TK-11503: resolve+VALIDATE the token FIRST, then sync. A token failure now
+// throws loudly here instead of the old bare-`||` path that could sync 0 products silently.
 const quickMode = process.argv.includes('--quick');
-syncProducts(quickMode).catch(err => {
-  console.error('Fatal error:', err);
-  process.exit(1);
-});
+
+// TK-11503 testability seam (CLAUDE.md TK-11431 amendment 3): `--resolve-only` validates
+// the token via read-only probes and exits WITHOUT syncing — so the fix can be proven to
+// go green (a live token) or red (none valid) without any mirror write or launchd kickstart.
+// The launchd plists pass `--quick`, NEVER `--resolve-only`, so a scheduled run never no-ops.
+if (process.argv.includes('--resolve-only')) {
+  resolveToken()
+    .then(() => { console.log('✅ resolve-only: a candidate token validated read_products.'); process.exit(0); })
+    .catch(err => { console.error('❌ resolve-only:', err.message); process.exit(1); });
+} else {
+  resolveToken()
+    .then(() => syncProducts(quickMode))
+    .catch(err => {
+      console.error('Fatal error:', err);
+      process.exit(1);
+    });
+}
 
 module.exports = { skuExists };

← ff460585 cron-create-sample-variants: pass --apply (script is now dry  ·  back to Designer Wallcoverings  ·  fix(TK-11357/TK-11403/TK-11539): create DRAFT then gate befo 624ad3ff →