← 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
M package.jsonM scripts/build-colorwheel-reel.mjsM scripts/build-reel.mjsM scripts/cron-run.shM scripts/fetch-new-arrivals.mjsA scripts/leak-deny.mjsA scripts/scan-leaks.mjs
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →