← 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
M shopify/scripts/sync-shopify-products.js
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 →