[object Object]

← 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

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 →