[object Object]

← back to Dw Marketing Reels

reels: consolidate LEAK_DENY to one canonical-synced module + fail-closed leak gate

19e8ca46e1be7cef5f59152d4d69052e7cc3141f · 2026-07-27 16:20:45 -0700 · Steve Abrams

- scripts/leak-deny.mjs: single source of truth for the private-label denylist,
  synced to canonical dw-leak-scanner. Adds names the 3 hand-copied regexes had
  DRIFTED and were missing: lillian august (leaked 2026-07-06), rigo, rigowall.
  Per-term anchoring: leading-\b on 'versa' (catches standalone + concatenated,
  safe on Adversarial/conversation/Universal), both-\b on 'rigo' (protects Rigoletto).
- build-reel / build-colorwheel-reel / fetch-new-arrivals: import the shared module,
  removing 3 drift-prone copies.
- scripts/scan-leaks.mjs + npm 'scan': fail-closed gate over the staged manifests.
- cron-run.sh: run the gate BEFORE publish+push — a leaked name can no longer reach
  a public post. Turns the old one-shot '0 leaks' assertion into a standing gate.

Verified: 22/22 leak+FP assertions pass; gate PASSES on the 33 staged reels.
TK-14. Contrarian-gated (FIX FIRST verdict applied).

Files touched

Diff

commit 19e8ca46e1be7cef5f59152d4d69052e7cc3141f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 27 16:20:45 2026 -0700

    reels: consolidate LEAK_DENY to one canonical-synced module + fail-closed leak gate
    
    - scripts/leak-deny.mjs: single source of truth for the private-label denylist,
      synced to canonical dw-leak-scanner. Adds names the 3 hand-copied regexes had
      DRIFTED and were missing: lillian august (leaked 2026-07-06), rigo, rigowall.
      Per-term anchoring: leading-\b on 'versa' (catches standalone + concatenated,
      safe on Adversarial/conversation/Universal), both-\b on 'rigo' (protects Rigoletto).
    - build-reel / build-colorwheel-reel / fetch-new-arrivals: import the shared module,
      removing 3 drift-prone copies.
    - scripts/scan-leaks.mjs + npm 'scan': fail-closed gate over the staged manifests.
    - cron-run.sh: run the gate BEFORE publish+push — a leaked name can no longer reach
      a public post. Turns the old one-shot '0 leaks' assertion into a standing gate.
    
    Verified: 22/22 leak+FP assertions pass; gate PASSES on the 33 staged reels.
    TK-14. Contrarian-gated (FIX FIRST verdict applied).
---
 package.json                      |  3 ++-
 scripts/build-colorwheel-reel.mjs |  6 +++---
 scripts/build-reel.mjs            |  8 ++++----
 scripts/cron-run.sh               |  6 ++++++
 scripts/fetch-new-arrivals.mjs    |  7 ++++---
 scripts/leak-deny.mjs             | 35 +++++++++++++++++++++++++++++++
 scripts/scan-leaks.mjs            | 43 +++++++++++++++++++++++++++++++++++++++
 7 files changed, 97 insertions(+), 11 deletions(-)

diff --git a/package.json b/package.json
index d6c33b6..f8ac12f 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
   "scripts": {
     "start": "node server.js",
     "fetch": "node scripts/fetch-new-arrivals.mjs",
-    "build-reel": "node scripts/build-reel.mjs"
+    "build-reel": "node scripts/build-reel.mjs",
+    "scan": "node scripts/scan-leaks.mjs"
   }
 }
diff --git a/scripts/build-colorwheel-reel.mjs b/scripts/build-colorwheel-reel.mjs
index 23d494a..dca1cf6 100644
--- a/scripts/build-colorwheel-reel.mjs
+++ b/scripts/build-colorwheel-reel.mjs
@@ -13,6 +13,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readd
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { execSync } from 'node:child_process';
+import { safe } from './leak-deny.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const WS = join(ROOT, 'hf-workspace');
@@ -26,9 +27,8 @@ const PER_CARD = 3;        // max product cards shown per hue
 
 const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
 
-// Private-label / upstream names that must NEVER surface publicly (mirrors build-reel.mjs).
-const LEAK_DENY = /greenland|wallquest|chesapeake|nextwall|seabrook|brewster|command\s*54|momentum|versa|desima|carlsten|nicolette\s*mayer|york\s*wall/i;
-const safe = s => s && !LEAK_DENY.test(s);
+// Private-label / upstream leak filter (safe) imported from ./leak-deny.mjs — single
+// source synced to canonical dw-leak-scanner.
 
 // HSL→hex at l=0.5, matching the live shop-by-color widget's wheel math.
 function hsl2hex(h, s, l = 0.5) {
diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs
index a06b76b..452e2af 100644
--- a/scripts/build-reel.mjs
+++ b/scripts/build-reel.mjs
@@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readd
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { execSync } from 'node:child_process';
+import { LEAK_DENY, safe } from './leak-deny.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const WS = join(ROOT, 'hf-workspace');
@@ -31,10 +32,9 @@ const THEME = {
 
 const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
 
-// Private-label / upstream names that must NEVER surface publicly (mirrors dw-leak-scanner).
-// Captions pull from the LIVE storefront, so defend against any leak reaching IG/TikTok.
-const LEAK_DENY = /greenland|wallquest|chesapeake|nextwall|seabrook|brewster|command\s*54|momentum|versa|desima|carlsten|nicolette\s*mayer|york\s*wall/i;
-const safe = s => s && !LEAK_DENY.test(s);
+// Private-label / upstream leak filter (LEAK_DENY + safe) imported from ./leak-deny.mjs —
+// single source synced to canonical dw-leak-scanner. Captions pull from the LIVE storefront,
+// so no denied vendor name reaches IG/TikTok.
 
 // On-brand social caption + hashtags from the reel's products ($0 local, deterministic).
 function captionFor(items) {
diff --git a/scripts/cron-run.sh b/scripts/cron-run.sh
index fa8d800..9366efe 100755
--- a/scripts/cron-run.sh
+++ b/scripts/cron-run.sh
@@ -8,6 +8,12 @@ cd "$(dirname "$0")/.."
 
 node scripts/fetch-new-arrivals.mjs && node scripts/build-reel.mjs || exit 1
 
+# LEAK GATE (fail-closed, 2026-07-27): a private-label / upstream name in the staged
+# manifests must NEVER reach a public post or the console. If scan-leaks finds a hit it
+# exits non-zero and we abort BEFORE publish + push. Turns the old one-shot manual "0
+# leaks" check into a standing gate.
+node scripts/scan-leaks.mjs || { echo "LEAK GATE FAILED — aborting publish+push"; exit 1; }
+
 # auto-post (Steve: auto-post on the nightly schedule). Runs on Mac3 where Norma/tiktok live.
 SOCIAL_AUTOPOST="${SOCIAL_AUTOPOST:-1}" node scripts/publish-social.mjs
 
diff --git a/scripts/fetch-new-arrivals.mjs b/scripts/fetch-new-arrivals.mjs
index 86028c4..a75fcd7 100644
--- a/scripts/fetch-new-arrivals.mjs
+++ b/scripts/fetch-new-arrivals.mjs
@@ -5,6 +5,7 @@
 import { writeFileSync, mkdirSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
+import { LEAK_DENY } from './leak-deny.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const STORE = process.env.DW_STORE || 'https://designerwallcoverings.com';
@@ -25,9 +26,9 @@ const DEDUPE = process.env.DW_DEDUPE_PATTERN === '1';
 const SPREAD = Number(process.env.DW_SPREAD || 0);
 // Swatch/sample books are never a reel frame.
 const NON_PRODUCT = /collection book|swatch book|sample book|memo sample/i;
-// Private-label / upstream names that must NEVER surface publicly (mirrors build-reel.mjs).
-// Slide cards render vendor names verbatim, so denied vendors must never enter the feed.
-const LEAK_DENY = /greenland|wallquest|chesapeake|nextwall|seabrook|brewster|command\s*54|momentum|versa|desima|carlsten|nicolette\s*mayer|york\s*wall/i;
+// Private-label / upstream leak filter (LEAK_DENY) imported from ./leak-deny.mjs — single
+// source synced to canonical dw-leak-scanner. Slide cards render vendor names verbatim,
+// so denied vendors must never enter the feed.
 
 function cleanTitle(t = '') {
   // "Katamari, Pewter Wallcoverings | Thibaut" -> {pattern:"Katamari", color:"Pewter", vendor:"Thibaut"}
diff --git a/scripts/leak-deny.mjs b/scripts/leak-deny.mjs
new file mode 100644
index 0000000..c209b59
--- /dev/null
+++ b/scripts/leak-deny.mjs
@@ -0,0 +1,35 @@
+// Single source of truth for the private-label / upstream-vendor LEAK denylist used
+// across the reels pipeline (build-reel, build-colorwheel-reel, fetch-new-arrivals,
+// scan-leaks). These names must NEVER surface on a public IG/TikTok post or slide card.
+//
+// SYNCED TO CANONICAL: ~/.claude/skills/dw-leak-scanner/denylist.json is the fleet
+// source of truth. When that list changes, mirror it here (and in the rotation-activator
+// leak guard). Previously this list was hand-copied into 3 builder files and DRIFTED —
+// it was missing `lillian august` (which actually leaked 2026-07-06), `rigo`, `rigowall`.
+// Consolidated here 2026-07-27 so a caption/card leak can't slip through one stale copy.
+//
+// Per-term anchoring mirrors the canonical FP notes:
+//   • unambiguous names           → substring match (also catches concatenated handles)
+//   • `versa` (Momentum sub-brand) → LEADING boundary: catches "Versa" + "versadesignedsurfaces"
+//     yet stays safe on "Adversarial"/"conversation"/"Universal"/"Anniversary"
+//   • `rigo`  (RIGO Wallcovering)  → BOTH boundaries: canonical note "Rigoletto etc. don't match"
+const PARTS = [
+  // unambiguous upstream/private-label names — substring (case-insensitive)
+  'greenland', 'wallquest', 'chesapeake', 'nextwall', 'seabrook', 'brewster',
+  'momentum', 'desima', 'carlsten', 'yorkwall', 'rigowall',
+  'nicolette\\s*mayer', 'york\\s*wall', 'command\\s*54',
+  'lillian\\s*august',            // Lillian August (Seabrook/York) → PR coastal; leaked 2026-07-06
+  'versa\\s+designed\\s+surfaces',
+  'rigo\\s+wallcovering',
+  // ambiguous short tokens — anchored per canonical notes
+  '\\bversa',                     // leading boundary (standalone + concatenated), FP-safe
+  '\\brigo\\b',                   // both boundaries — protect "Rigoletto"
+];
+
+export const LEAK_DENY = new RegExp(PARTS.join('|'), 'i');
+
+// True when a private-label name is present anywhere in `s`.
+export const isLeak = s => LEAK_DENY.test(String(s || ''));
+
+// True when `s` is non-empty AND carries no leak (the builders' existing `safe()` contract).
+export const safe = s => !!s && !LEAK_DENY.test(s);
diff --git a/scripts/scan-leaks.mjs b/scripts/scan-leaks.mjs
new file mode 100644
index 0000000..4e12668
--- /dev/null
+++ b/scripts/scan-leaks.mjs
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+// Fail-closed LEAK GATE for the reels pipeline. Scans the staged manifests
+// (data/reels.json, data/new-arrivals.json) for any private-label / upstream
+// vendor name using the single canonical-synced denylist (./leak-deny.mjs), and
+// EXITS NON-ZERO on any hit. Wired into cron-run.sh BEFORE publish-social.mjs so a
+// leaked caption can never reach a live IG/TikTok post — turning the old one-shot
+// "0 leaks" manual assertion into a standing gate. $0 (local, read-only).
+import { readFileSync, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { LEAK_DENY } from './leak-deny.mjs';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const load = f => { try { return JSON.parse(readFileSync(join(ROOT, 'data', f), 'utf8')); } catch { return null; } };
+
+// Walk every string value in an object/array and yield [path, value].
+function* strings(node, path = '') {
+  if (node == null) return;
+  if (typeof node === 'string') { yield [path, node]; return; }
+  if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) yield* strings(node[i], `${path}[${i}]`); return; }
+  if (typeof node === 'object') { for (const k of Object.keys(node)) yield* strings(node[k], path ? `${path}.${k}` : k); }
+}
+
+const hits = [];
+for (const f of ['reels.json', 'new-arrivals.json']) {
+  const data = load(f);
+  if (data == null) { console.log(`· ${f}: absent/unreadable — skipped`); continue; }
+  let n = 0;
+  for (const [path, val] of strings(data)) {
+    n++;
+    const m = val.match(LEAK_DENY);
+    if (m) hits.push({ file: f, path, match: m[0], sample: val.slice(0, 100) });
+  }
+  console.log(`· ${f}: scanned ${n} string fields`);
+}
+
+if (hits.length) {
+  console.error(`\n✗ LEAK GATE FAILED — ${hits.length} private-label name(s) staged:`);
+  for (const h of hits) console.error(`  ${h.file} @ ${h.path} → "${h.match}"  ::  ${h.sample}`);
+  console.error(`\nRefusing to publish. Fix the source data / caption, then re-run.`);
+  process.exit(1);
+}
+console.log(`\n✓ LEAK GATE PASSED — no private-label names in staged manifests.`);

← 4f97f1f reels: guard against duplicate social post of an already-'po  ·  back to Dw Marketing Reels  ·  reels: honest TikTok status + FTC content-claims hold guard 5121ca1 →