[object Object]

← back to Designerwallcoverings

TK-12267: tripwire enumerate uses read-only ADMIN-first seam (read-lib.mjs); 0-of-0 population guard; lib.mjs write loader untouched

820535f11e35ba228e282e5e6784e8591d0b2efa · 2026-09-25 13:48:25 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX

Files touched

Diff

commit 820535f11e35ba228e282e5e6784e8591d0b2efa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 13:48:25 2026 -0700

    TK-12267: tripwire enumerate uses read-only ADMIN-first seam (read-lib.mjs); 0-of-0 population guard; lib.mjs write loader untouched
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
---
 scripts/tk11357-zero-price-stopgap/enumerate.mjs   |  6 +-
 scripts/tk11357-zero-price-stopgap/read-lib.mjs    | 74 ++++++++++++++++++++++
 .../tk11357-zero-price-stopgap/test-read-lib.mjs   | 10 +++
 scripts/tk11357-zero-price-stopgap/tripwire.sh     |  3 +-
 4 files changed, 91 insertions(+), 2 deletions(-)

diff --git a/scripts/tk11357-zero-price-stopgap/enumerate.mjs b/scripts/tk11357-zero-price-stopgap/enumerate.mjs
index 8f48100..3550bc3 100644
--- a/scripts/tk11357-zero-price-stopgap/enumerate.mjs
+++ b/scripts/tk11357-zero-price-stopgap/enumerate.mjs
@@ -31,7 +31,7 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
-import { gql, rest, getLocations, SHOP, TOKEN_LAST4 } from './lib.mjs';
+import { gql, rest, getLocations, SHOP, TOKEN_LAST4 } from './read-lib.mjs'; // TK-12267: read-only seam, ADMIN->FULL
 
 // Import the canary's authoritative predicate so the scope is provably identical.
 const CANARY = pathToFileURL(path.join(process.env.HOME, '.claude/skills/zero-price-orderable-canary/check.mjs')).href;
@@ -114,6 +114,8 @@ async function main() {
   // Enforce the canary's scope boundary locally (Shopify OR-grammar can over-return).
   const scoped = uniqueAll.filter(inScope);
   console.log(`[enumerate] in-scope (quote-tag family OR Fentucci): ${scoped.length}`);
+  // TK-12267 / TK-11431 amendment 1: 0-of-0 is NOT MEASURED, never an empty-cohort PASS.
+  if (scoped.length === 0) { console.error('NOT MEASURED: in-scope population is 0 (query/auth/scope broken?)'); process.exit(2); }
 
   // ---- Identify flagged non-sample $0 orderable variants ----
   const targets = [];
@@ -181,6 +183,8 @@ async function main() {
     predicate_source: '~/.claude/skills/zero-price-orderable-canary/check.mjs (SEARCHES/inScope/badVariant)',
     stopgap: 'zero every nonzero inventory level on each flagged variant so availableForSale=false (policy=DENY)',
     canary_target_count: 1255,
+    population_unique_products: uniqueAll.length,   // TK-12267: population beside observed
+    population_in_scope: scoped.length,
     products_with_canary_hit: productsWithCanaryHit,
     flagged_products: flaggedProductIds.size,
     total_targets: targets.length,
diff --git a/scripts/tk11357-zero-price-stopgap/read-lib.mjs b/scripts/tk11357-zero-price-stopgap/read-lib.mjs
new file mode 100644
index 0000000..07b2c47
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/read-lib.mjs
@@ -0,0 +1,74 @@
+/**
+ * TK-12267 — READ-ONLY Shopify seam for enumerate.mjs (the $0-orderable tripwire's detector).
+ *
+ * Why a separate file: lib.mjs's TOKEN also feeds apply.mjs + rollback.mjs (inventory WRITES),
+ * so its loader is deliberately NOT changed here (write-path token change = gated). This seam
+ * serves READS only and prefers the live SHOPIFY_ADMIN_TOKEN (now the Full Access app, TK-12079)
+ * with SHOPIFY_FULL_ACCESS_TOKEN (…2ea5, 401 since 2026-09-22) as fallback.
+ *
+ * Hard read-only guards: gql() refuses any `mutation`; rest() refuses any non-GET method.
+ * Test seam: ZPT_READ_TOKEN_OVERRIDE is honoured ONLY when argv contains --test (plists never pass it).
+ */
+import fs from 'node:fs';
+
+export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+export const VER = '2024-10';
+export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
+const GQL_URL = `${ENDPOINT}/graphql.json`;
+
+const TEST = process.argv.includes('--test');
+const _env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+const pick = k => (_env.match(new RegExp(`^${k}=(.+)$`, 'm')) || [])[1]?.trim();
+export const TOKEN_SOURCE = (TEST && process.env.ZPT_READ_TOKEN_OVERRIDE) ? 'TEST_OVERRIDE'
+  : pick('SHOPIFY_ADMIN_TOKEN') ? 'SHOPIFY_ADMIN_TOKEN' : 'SHOPIFY_FULL_ACCESS_TOKEN';
+export const TOKEN = TOKEN_SOURCE === 'TEST_OVERRIDE' ? process.env.ZPT_READ_TOKEN_OVERRIDE : pick(TOKEN_SOURCE);
+if (!TOKEN) {
+  console.error('FATAL: neither SHOPIFY_ADMIN_TOKEN nor SHOPIFY_FULL_ACCESS_TOKEN in ~/Projects/secrets-manager/.env');
+  process.exit(1);
+}
+export const TOKEN_LAST4 = TOKEN.slice(-4);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+export async function gql(query, vars) {
+  if (/^\s*mutation\b/i.test(query) || /\bmutation\s*[\w({]/i.test(query)) throw new Error('read-lib: mutation refused (read-only seam)');
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(GQL_URL, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables: vars }),
+      });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) {
+      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+      return { __err: j.errors };
+    }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 400) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+export async function rest(path, { method = 'GET', body } = {}, tries = 6) {
+  if (method !== 'GET' || body) throw new Error(`read-lib: ${method} refused (read-only seam)`);
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`${ENDPOINT}${path}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+    if (r.status === 429 || r.status >= 500) { await sleep(1800 * (i + 1)); continue; }
+    await sleep(120);
+    return r;
+  }
+  throw new Error('rest fail ' + path);
+}
+
+export async function getLocations() {
+  const d = await gql(`{locations(first:50){nodes{id name}}}`);
+  if (d?.__err) throw new Error('cannot read locations: ' + JSON.stringify(d.__err).slice(0, 300));
+  if (!d?.locations) throw new Error('cannot read locations: empty/unauthorized response');
+  const out = {};
+  for (const n of d.locations.nodes) out[n.id.split('/').pop()] = n.name;
+  return out;
+}
diff --git a/scripts/tk11357-zero-price-stopgap/test-read-lib.mjs b/scripts/tk11357-zero-price-stopgap/test-read-lib.mjs
new file mode 100644
index 0000000..8ba3c45
--- /dev/null
+++ b/scripts/tk11357-zero-price-stopgap/test-read-lib.mjs
@@ -0,0 +1,10 @@
+// TK-12267 negative test for read-lib.mjs. Run: node test-read-lib.mjs --test
+import assert from 'node:assert';
+process.env.ZPT_READ_TOKEN_OVERRIDE = 'shpat_deadbeefdeadbeefdeadbeef0000';
+const L = await import('./read-lib.mjs');
+assert.equal(L.TOKEN_SOURCE, 'TEST_OVERRIDE');
+let red = false; try { await L.getLocations(); } catch { red = true; }
+assert.ok(red, 'dead token must make getLocations throw (enumerate FAIL)');
+await assert.rejects(() => L.gql('mutation { x }'), /refused/);
+await assert.rejects(() => L.rest('/inventory_levels/set.json', { method: 'POST', body: {} }), /refused/);
+console.log('NEGATIVE TEST PASS: dead token -> RED; mutation + POST refused');
diff --git a/scripts/tk11357-zero-price-stopgap/tripwire.sh b/scripts/tk11357-zero-price-stopgap/tripwire.sh
index db34917..0821590 100755
--- a/scripts/tk11357-zero-price-stopgap/tripwire.sh
+++ b/scripts/tk11357-zero-price-stopgap/tripwire.sh
@@ -96,7 +96,8 @@ PY
 
 if [ "${DET:-0}" -eq 0 ]; then
   echo "$TS OK — 0 zero-price-orderable variants, no write" >>"$LOG"
-  heartbeat PASS 0 0 0 0 0 "cohort empty: no ACTIVE \$0 sellable variant is orderable"
+  POP="$(python3 -c 'import json;print(json.load(open("targets.json")).get("population_in_scope","?"))')"
+  heartbeat PASS 0 0 0 0 0 "cohort empty: 0 of ${POP} in-scope ACTIVE products has an orderable \$0 sellable variant"
   exit 0
 fi
 

← f47fd6c TK-11076: Option B — undo DELETEs a metafield the forward ru  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-25T18:05:08 (1 data files) — dat f2ea7ec →