← back to Dw Signup Fulfillment
TK-11190: harden recover-stuck-apps — fail-loud --apply DRY_RUN guard + idempotent --send-only
90c2cb8969bca67024170e0323e6dfc90650b4fe · 2026-09-03 12:39:20 -0700 · Steve Abrams
Closes the footgun that stamped fake dry-run stub customer ids into the prod jsonl:
a standalone `node recover-stuck-apps.js --apply` inherits DRY_RUN=1 (config reads it only
from process.env; pm2 env injection doesn't reach a bare node invocation), and the APPLY
branch wrote unconditionally.
- FAIL-LOUD: --apply now refuses (clear message + exit 1) while config.DRY_RUN is on, BEFORE
any Shopify call or jsonl write. Real runs must set DRY_RUN=0.
- --send-only: emails the activation + office letters for apps that are LINKED but not-yet-
emailed, tracked by a recovery_emailed flag so re-sends are idempotent (no restore-and-
reapply dance). Emails stay DRY_RUN-safe — a DRY preview does NOT consume the flag, so a
later real run still sends. Shared sendForApp() sets the flag ONLY on a real send.
- selftest (c4): spawns the script to prove the guard trips (exit non-zero + refuses) and
--send-only targets only linked+not-emailed without consuming the flag on a dry preview.
Full DRY_RUN suite green. LOCAL only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3co77k7JzTkAJRdemt6Ru
Files touched
M scripts/recover-stuck-apps.jsM scripts/selftest.js
Diff
commit 90c2cb8969bca67024170e0323e6dfc90650b4fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 12:39:20 2026 -0700
TK-11190: harden recover-stuck-apps — fail-loud --apply DRY_RUN guard + idempotent --send-only
Closes the footgun that stamped fake dry-run stub customer ids into the prod jsonl:
a standalone `node recover-stuck-apps.js --apply` inherits DRY_RUN=1 (config reads it only
from process.env; pm2 env injection doesn't reach a bare node invocation), and the APPLY
branch wrote unconditionally.
- FAIL-LOUD: --apply now refuses (clear message + exit 1) while config.DRY_RUN is on, BEFORE
any Shopify call or jsonl write. Real runs must set DRY_RUN=0.
- --send-only: emails the activation + office letters for apps that are LINKED but not-yet-
emailed, tracked by a recovery_emailed flag so re-sends are idempotent (no restore-and-
reapply dance). Emails stay DRY_RUN-safe — a DRY preview does NOT consume the flag, so a
later real run still sends. Shared sendForApp() sets the flag ONLY on a real send.
- selftest (c4): spawns the script to prove the guard trips (exit non-zero + refuses) and
--send-only targets only linked+not-emailed without consuming the flag on a dry preview.
Full DRY_RUN suite green. LOCAL only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3co77k7JzTkAJRdemt6Ru
---
scripts/recover-stuck-apps.js | 67 ++++++++++++++++++++++++++++++++++---------
scripts/selftest.js | 31 ++++++++++++++++++++
2 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/scripts/recover-stuck-apps.js b/scripts/recover-stuck-apps.js
index bbc4a3c..22e679c 100644
--- a/scripts/recover-stuck-apps.js
+++ b/scripts/recover-stuck-apps.js
@@ -18,11 +18,17 @@
// without it, linking happens with ZERO email. Emails go out via George.
// * Rate-friendly — a short delay between Shopify calls.
//
+// FAIL-LOUD: `--apply` REFUSES to run while config.DRY_RUN is on (exit 1) — a bare
+// `node recover-stuck-apps.js --apply` inherits DRY_RUN=1 and would stamp fake stub ids
+// into the jsonl (the TK-11190 footgun). Real runs MUST set DRY_RUN=0.
+//
// Usage:
-// node scripts/recover-stuck-apps.js # dry-run, probes create-vs-reuse
-// node scripts/recover-stuck-apps.js --apply --limit 25 # link 25, NO emails
-// node scripts/recover-stuck-apps.js --apply --limit 25 --send-emails # link + activation/office emails
-// node scripts/recover-stuck-apps.js --rollback data/recovery-<ts>.json # delete the customers we created
+// node scripts/recover-stuck-apps.js # dry-run, probes create-vs-reuse
+// DRY_RUN=0 node scripts/recover-stuck-apps.js --apply --limit 25 # link 25, NO emails
+// DRY_RUN=0 node scripts/recover-stuck-apps.js --apply --limit 25 --send-emails # link + email
+// DRY_RUN=0 node scripts/recover-stuck-apps.js --send-only --limit 25 # email already-LINKED apps (idempotent)
+// node scripts/recover-stuck-apps.js --send-only --limit 25 # DRY preview of the sends (no send, no flag)
+// DRY_RUN=0 node scripts/recover-stuck-apps.js --rollback data/recovery-<ts>.json # delete customers we created
const fs = require('fs');
const path = require('path');
@@ -36,6 +42,7 @@ const has = (f) => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
const APPLY = has('--apply');
const SEND_EMAILS = has('--send-emails');
+const SEND_ONLY = has('--send-only'); // email already-LINKED apps that weren't emailed yet
const AUTO_APPROVE = has('--auto-approve'); // option (b) — NOT recommended
const NO_PROBE = has('--no-probe');
const LIMIT = parseInt(val('--limit', '25'), 10);
@@ -68,8 +75,48 @@ async function rollback() {
console.log(`[rollback] done. Restore the jsonl with: cp "${map.jsonl_backup}" "${FILE}"`);
}
+// Send the two option-(a) letters for one LINKED app. Sets recovery_emailed ONLY on a
+// REAL send (a DRY_RUN preview returns {dryRun:true} and must NOT consume the flag, or a
+// later real run would skip the app). Returns true iff the flag was set (real send).
+async function sendForApp(app) {
+ const { firstName } = firstLast(app);
+ const t = email.designerAccountReadyEmail({ firstName: firstName || app.email.split('@')[0] });
+ const m = await email.sendEmail({ to: app.email, subject: t.subject, html: t.html, source: 'trade-recovery-activation' });
+ const ot = email.tradeApplicationEmail({ app, approveUrl: `${config.PUBLIC_URL || ''}/admin/trade`, rejectUrl: `${config.PUBLIC_URL || ''}/admin/trade`, adminUrl: `${config.PUBLIC_URL || ''}/admin/trade` });
+ const om = await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject: '[Now approvable] ' + ot.subject, html: ot.html, source: 'trade-recovery-office' });
+ const realSend = (m.ok !== false && !m.dryRun);
+ console.log(` [email:designer] ${app.email} ok=${m.ok !== false} dryRun=${m.dryRun || false} [email:office] ${config.TRADE_NOTIFY_TO} dryRun=${om.dryRun || false}`);
+ if (realSend) { app.recovery_emailed = true; app.recovery_emailed_at = new Date().toISOString(); }
+ else console.log(` (DRY preview — recovery_emailed NOT set; a real DRY_RUN=0 run will still send)`);
+ return realSend;
+}
+
+// --send-only: email apps that are LINKED but not-yet-emailed. No Shopify writes, no id
+// stamping — only sets recovery_emailed on a real send. Idempotent (skips already-emailed).
+async function sendOnly() {
+ const rows = readRows();
+ const targets = rows.filter((a) => a.link_status === 'linked' && a.shopify_customer_id && !a.recovery_emailed).slice(0, LIMIT);
+ console.log(`--send-only DRY_RUN(config)=${config.DRY_RUN} file=${FILE}`);
+ console.log(`LINKED not-yet-emailed=${targets.length} (of ${rows.filter((a) => a.link_status === 'linked').length} linked; limit ${LIMIT})`);
+ if (!targets.length) { console.log('Nothing to email.'); return; }
+ let sent = 0;
+ for (const app of targets) { if (await sendForApp(app)) sent++; await sleep(DELAY_MS); }
+ if (sent > 0) { writeRows(rows); console.log(`\nSent ${sent}; recovery_emailed stamped + jsonl updated.`); }
+ else console.log(`\nNo real sends (DRY_RUN preview) — jsonl untouched, flags preserved for a real run.`);
+}
+
async function main() {
if (ROLLBACK) return rollback();
+ if (SEND_ONLY) return sendOnly();
+
+ // FAIL-LOUD (TK-11190 footgun): a standalone `node recover-stuck-apps.js --apply` inherits
+ // DRY_RUN=1 (config reads it only from process.env; pm2 env injection doesn't reach a bare
+ // node invocation). The APPLY branch would then stamp FAKE synthetic customer ids into the
+ // jsonl (writeRows is unconditional). Refuse BEFORE any Shopify call or write.
+ if (APPLY && config.DRY_RUN) {
+ console.error('REFUSING --apply while DRY_RUN is on: this would stamp fake dry-run stub customer ids into the jsonl. Re-run with DRY_RUN=0 (e.g. `DRY_RUN=0 node scripts/recover-stuck-apps.js --apply`).');
+ process.exit(1);
+ }
const rows = readRows();
const stuck = rows.filter((a) => a.status === 'pending' && !a.shopify_customer_id);
@@ -116,19 +163,13 @@ async function main() {
console.log(`\nLinked ${recovered.length}. jsonl backup: ${jsonlBak} recovery map (rollback): ${mapPath}`);
if (SEND_EMAILS) {
+ let sent = 0;
for (const rec of recovered) {
const app = rows.find((a) => a.id === rec.app_id);
- const { firstName } = firstLast(app);
- // (a) designer activation letter (EXTERNAL send — gated)
- const t = email.designerAccountReadyEmail({ firstName: firstName || app.email.split('@')[0] });
- const m = await email.sendEmail({ to: app.email, subject: t.subject, html: t.html, source: 'trade-recovery-activation' });
- console.log(` [email:designer] ${app.email} ok=${m.ok !== false} dryRun=${m.dryRun || false}`);
- // office re-surface as now-approvable (INTERNAL inbox)
- const ot = email.tradeApplicationEmail({ app, approveUrl: `${config.PUBLIC_URL || ''}/admin/trade`, rejectUrl: `${config.PUBLIC_URL || ''}/admin/trade`, adminUrl: `${config.PUBLIC_URL || ''}/admin/trade` });
- const om = await email.sendEmail({ to: config.TRADE_NOTIFY_TO, subject: '[Now approvable] ' + ot.subject, html: ot.html, source: 'trade-recovery-office' });
- console.log(` [email:office] ${config.TRADE_NOTIFY_TO} ok=${om.ok !== false} dryRun=${om.dryRun || false}`);
+ if (await sendForApp(app)) sent++;
await sleep(DELAY_MS);
}
+ if (sent > 0) { writeRows(rows); console.log(` emailed ${sent}; recovery_emailed stamped + jsonl updated.`); }
} else {
console.log(`No emails sent (--send-emails not set). Apps are LINKED and now approvable at /admin/trade.`);
}
diff --git a/scripts/selftest.js b/scripts/selftest.js
index e0623e9..c0dfbb8 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -238,6 +238,37 @@ async function main() {
if (config.TRADE_APPLY_RATE_MAX === 5 && config.TRADE_APPLY_RATE_WINDOW_MS === 3600000) ok('config default = 5 applications per IP per hour');
else fail('unexpected trade-apply rate config: max=' + config.TRADE_APPLY_RATE_MAX + ' window=' + config.TRADE_APPLY_RATE_WINDOW_MS);
+ // ---------------------------------------------------------------------------
+ // TK-11190 — recovery script hardening: fail-loud --apply guard + idempotent --send-only.
+ hr('(c4) TK-11190 — recover-stuck-apps: fail-loud guard + --send-only idempotency');
+ const cp = require('child_process');
+ const script = path.join(__dirname, 'recover-stuck-apps.js');
+ // Guard: `--apply` while DRY_RUN=1 must exit non-zero BEFORE any write (the footgun fix).
+ let guardTripped = false, guardMsg = '';
+ try {
+ cp.execFileSync('node', [script, '--apply', '--file', '/tmp/tk11190-guard-should-not-read.jsonl'],
+ { env: { ...process.env, DRY_RUN: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (e) { guardTripped = true; guardMsg = String((e.stderr || '') + (e.stdout || '')); }
+ if (guardTripped && /REFUSING --apply while DRY_RUN/.test(guardMsg)) ok('--apply while DRY_RUN=1 → exits non-zero + refuses BEFORE any Shopify call/write');
+ else fail('fail-loud guard did not trip: tripped=' + guardTripped + ' msg=' + guardMsg.slice(0, 120));
+
+ // --send-only: targets ONLY linked+not-emailed; a DRY preview does NOT consume the flag.
+ const soFix = path.join(os.tmpdir(), 'tk11190-selftest-sendonly.jsonl');
+ fs.writeFileSync(soFix, [
+ JSON.stringify({ id: 'L1', email: 'need@x.com', status: 'pending', created_at: '2026-07-10T10:00:00Z', shopify_customer_id: '999001', link_status: 'linked' }),
+ JSON.stringify({ id: 'L2', email: 'done@x.com', status: 'pending', created_at: '2026-07-11T10:00:00Z', shopify_customer_id: '999002', link_status: 'linked', recovery_emailed: true }),
+ JSON.stringify({ id: 'U3', email: 'unl@x.com', status: 'pending', created_at: '2026-07-12T10:00:00Z', shopify_customer_id: null }),
+ ].join('\n') + '\n');
+ const soOut = cp.execFileSync('node', [script, '--send-only', '--file', soFix],
+ { env: { ...process.env, DRY_RUN: '1' }, encoding: 'utf8' });
+ const soRows = fs.readFileSync(soFix, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
+ const l1After = soRows.find((r) => r.id === 'L1');
+ if (/LINKED not-yet-emailed=1\b/.test(soOut)) ok('--send-only targets ONLY linked+not-emailed (1 of 2 linked; already-emailed + unlinked excluded)');
+ else fail('--send-only selection wrong: ' + soOut.split('\n').find((l) => /not-yet-emailed/.test(l)));
+ if (!l1After.recovery_emailed) ok('--send-only DRY preview does NOT set recovery_emailed → idempotent (a real run still sends)');
+ else fail('DRY preview wrongly consumed the recovery_emailed flag');
+ try { fs.unlinkSync(soFix); } catch {}
+
// ---------------------------------------------------------------------------
hr('(d) fixed assignment to the DW House Account');
const picks = [];
← 164ab38 TK-11190: recovery tooling for pre-fix stuck trade applicati
·
back to Dw Signup Fulfillment
·
retail-webhook: close double-fire race — in-process in-fligh 1944da5 →