[object Object]

← back to Costa Rica

costa-rica: harden boot guard per Cody gate — VERIFY_TOKEN + real-module tests — TK-10346

ddf9318975518fa0984d5714bbdec44583bea5f0 · 2026-08-08 08:18:40 -0700 · Steve

- Guard now also fails closed when WhatsApp is LIVE but WHATSAPP_VERIFY_TOKEN is missing/left at
  the public default 'cr-verify-sandbox' (new whatsapp.verifyTokenSet getter). Verified end-to-end:
  prod boot with live WhatsApp + default verify token now REFUSES to start.
- Added real env->module->guard INTEGRATION tests (fresh-require the actual tilopay/whatsapp modules,
  assert liveMode/webhookSecretSet/verifyTokenSet reflect env, and that verifyWebhook rejects when
  live+no-secret) — catches getter-rename/env-wiring regressions the pure-fake tests missed.
- Scoped the guard's comment honestly: it covers the webhook-secret/verify-token misconfig only,
  NOT the live createCharge-ref / sig-encoding items (those stay in the go-live memo preflight).
- Cody HOLE-1 (runs after app.listen) was a PHANTOM: runPreflight is synchronous BEFORE app.listen;
  if it throws the socket never binds. Dropped with evidence.
Suite 113/113.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ddf9318975518fa0984d5714bbdec44583bea5f0
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Aug 8 08:18:40 2026 -0700

    costa-rica: harden boot guard per Cody gate — VERIFY_TOKEN + real-module tests — TK-10346
    
    - Guard now also fails closed when WhatsApp is LIVE but WHATSAPP_VERIFY_TOKEN is missing/left at
      the public default 'cr-verify-sandbox' (new whatsapp.verifyTokenSet getter). Verified end-to-end:
      prod boot with live WhatsApp + default verify token now REFUSES to start.
    - Added real env->module->guard INTEGRATION tests (fresh-require the actual tilopay/whatsapp modules,
      assert liveMode/webhookSecretSet/verifyTokenSet reflect env, and that verifyWebhook rejects when
      live+no-secret) — catches getter-rename/env-wiring regressions the pure-fake tests missed.
    - Scoped the guard's comment honestly: it covers the webhook-secret/verify-token misconfig only,
      NOT the live createCharge-ref / sig-encoding items (those stay in the go-live memo preflight).
    - Cody HOLE-1 (runs after app.listen) was a PHANTOM: runPreflight is synchronous BEFORE app.listen;
      if it throws the socket never binds. Dropped with evidence.
    Suite 113/113.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/preflight.js       | 21 +++++++++++++----
 lib/whatsapp.js        |  6 ++++-
 test/preflight.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 82 insertions(+), 7 deletions(-)

diff --git a/lib/preflight.js b/lib/preflight.js
index e773e1a..8a31ff2 100644
--- a/lib/preflight.js
+++ b/lib/preflight.js
@@ -9,6 +9,11 @@
 // In production this THROWS (a live money system that cannot verify webhooks must not
 // serve — fail closed). In dev/sandbox it only warns. It is INERT when nothing is live,
 // so it can never affect the current sandbox prod.
+//
+// SCOPE: this guards the webhook-secret / verify-token misconfig ONLY. It does NOT prove
+// the live charge path is correct — the go-live memo's LIVE-ONLY preflight (createCharge
+// providerRef=undefined guard, base64-vs-hex signature encoding) must still be checked
+// against the real provider. "Boot passed" != "safe to open bookings".
 
 const SECRET_ENV = { tilopay: 'TILOPAY_WEBHOOK_SECRET', onvo: 'ONVO_WEBHOOK_SECRET' };
 
@@ -29,10 +34,18 @@ function checkWebhookSecrets({ getProvider, whatsapp } = {}) {
       problems.push(`payment provider preflight could not resolve: ${e && e.message}`);
     }
   }
-  if (whatsapp && whatsapp.liveMode && !whatsapp.webhookSecretSet) {
-    problems.push(
-      `WhatsApp is LIVE but WHATSAPP_APP_SECRET is missing — inbound webhooks would be silently rejected.`
-    );
+  if (whatsapp && whatsapp.liveMode) {
+    if (!whatsapp.webhookSecretSet) {
+      problems.push(
+        `WhatsApp is LIVE but WHATSAPP_APP_SECRET is missing — inbound webhooks would be silently rejected.`
+      );
+    }
+    if (!whatsapp.verifyTokenSet) {
+      problems.push(
+        `WhatsApp is LIVE but WHATSAPP_VERIFY_TOKEN is missing or left at the public default ` +
+        `('cr-verify-sandbox') — set a real random token before registering the Meta webhook.`
+      );
+    }
   }
   return problems;
 }
diff --git a/lib/whatsapp.js b/lib/whatsapp.js
index cafff53..8c834e5 100644
--- a/lib/whatsapp.js
+++ b/lib/whatsapp.js
@@ -150,7 +150,11 @@ async function handleInbound(body) {
 }
 
 module.exports = {
-  get liveMode() { return LIVE; }, get webhookSecretSet() { return !!APP_SECRET; }, VERIFY_TOKEN,
+  get liveMode() { return LIVE; }, get webhookSecretSet() { return !!APP_SECRET; },
+  // true only when a REAL (non-default) verify token is configured — the hardcoded
+  // 'cr-verify-sandbox' fallback must not be used for a live WABA (public repo default).
+  get verifyTokenSet() { return !!(process.env.WHATSAPP_VERIFY_TOKEN && VERIFY_TOKEN !== 'cr-verify-sandbox'); },
+  VERIFY_TOKEN,
   sendText, sendTemplate, sendButtons, sendList, sendImage, sendDocument, sendLocation, markRead,
   verifyChallenge, verifySignature, handleInbound, contactByWaId,
 };
diff --git a/test/preflight.test.js b/test/preflight.test.js
index 34f13a2..7dfecba 100644
--- a/test/preflight.test.js
+++ b/test/preflight.test.js
@@ -7,7 +7,7 @@ const { checkWebhookSecrets, runPreflight } = require('../lib/preflight');
 const liveNoSecretPay = { name: 'tilopay', liveMode: true, webhookSecretSet: false };
 const liveWithSecretPay = { name: 'tilopay', liveMode: true, webhookSecretSet: true };
 const sandboxPay = { name: 'tilopay', liveMode: false, webhookSecretSet: false };
-const wa = (live, secret) => ({ liveMode: live, webhookSecretSet: secret });
+const wa = (live, secret, vtoken = true) => ({ liveMode: live, webhookSecretSet: secret, verifyTokenSet: vtoken });
 
 test('checkWebhookSecrets: LIVE payment provider with NO webhook secret is flagged', () => {
   const p = checkWebhookSecrets({ getProvider: () => liveNoSecretPay });
@@ -48,6 +48,64 @@ test('runPreflight: dev/sandbox only WARNS (returns problems, does not throw)',
   assert.equal(out.length, 1); // problem surfaced (logged) but non-fatal off-prod
 });
 
+test('checkWebhookSecrets: LIVE WhatsApp with the DEFAULT verify token is flagged', () => {
+  const p = checkWebhookSecrets({ whatsapp: { liveMode: true, webhookSecretSet: true, verifyTokenSet: false } });
+  assert.equal(p.length, 1);
+  assert.match(p[0], /WHATSAPP_VERIFY_TOKEN/);
+});
+
 test('runPreflight: production with everything sandbox does NOT throw (guard is inert)', () => {
-  assert.doesNotThrow(() => runPreflight({ getProvider: () => sandboxPay, whatsapp: wa(false, false), env: { NODE_ENV: 'production' } }));
+  assert.doesNotThrow(() => runPreflight({ getProvider: () => sandboxPay, whatsapp: { liveMode: false, webhookSecretSet: false, verifyTokenSet: false }, env: { NODE_ENV: 'production' } }));
+});
+
+// INTEGRATION: exercise the REAL env -> module property -> guard chain (not fakes), so a
+// getter rename (webhookSecretSet/liveMode) or an env-wiring regression is actually caught.
+function freshRequire(relPath, env) {
+  const p = require.resolve(relPath);
+  const saved = {}; for (const k of Object.keys(env)) { saved[k] = process.env[k]; process.env[k] = env[k]; }
+  delete require.cache[p];
+  // tilopay/onvo are pulled in by lib/payments/index — bust those too
+  for (const dep of ['../lib/payments/index', '../lib/payments/tilopay', '../lib/payments/onvo', '../lib/whatsapp']) {
+    try { delete require.cache[require.resolve(dep)]; } catch {}
+  }
+  const mod = require(relPath);
+  const restore = () => { for (const k of Object.keys(env)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } };
+  return { mod, restore };
+}
+
+test('INTEGRATION: real tilopay module — LIVE creds + webhook secret => guard clean', () => {
+  const { mod, restore } = freshRequire('../lib/payments/tilopay',
+    { TILOPAY_API_USER: 'u', TILOPAY_API_PASSWORD: 'p', TILOPAY_API_KEY: 'k', TILOPAY_WEBHOOK_SECRET: 's' });
+  try {
+    assert.equal(mod.liveMode, true, 'real module should read LIVE from env');
+    assert.equal(mod.webhookSecretSet, true, 'real module should report secret set');
+    assert.deepEqual(checkWebhookSecrets({ getProvider: () => mod }), []);
+  } finally { restore(); }
+});
+
+test('INTEGRATION: real tilopay module — LIVE creds + NO webhook secret => guard flags it (the exact original bug)', () => {
+  const { mod, restore } = freshRequire('../lib/payments/tilopay',
+    { TILOPAY_API_USER: 'u', TILOPAY_API_PASSWORD: 'p', TILOPAY_API_KEY: 'k', TILOPAY_WEBHOOK_SECRET: '' });
+  try {
+    assert.equal(mod.liveMode, true);
+    assert.equal(mod.webhookSecretSet, false);
+    // and the runtime symptom the guard exists to prevent: verifyWebhook rejects when live+no-secret
+    assert.equal(mod.verifyWebhook({}, '{}').ok, false, 'live + no secret must reject the webhook');
+    const p = checkWebhookSecrets({ getProvider: () => mod });
+    assert.equal(p.length, 1);
+    assert.match(p[0], /TILOPAY_WEBHOOK_SECRET/);
+  } finally { restore(); }
+});
+
+test('INTEGRATION: real whatsapp module — LIVE + default verify token => guard flags VERIFY_TOKEN', () => {
+  const { mod, restore } = freshRequire('../lib/whatsapp',
+    { WHATSAPP_TOKEN: 't', WHATSAPP_PHONE_ID: 'pid', WHATSAPP_APP_SECRET: 'sec' }); // no WHATSAPP_VERIFY_TOKEN
+  try {
+    assert.equal(mod.liveMode, true);
+    assert.equal(mod.webhookSecretSet, true);
+    assert.equal(mod.verifyTokenSet, false, 'default verify token must not count as set');
+    const p = checkWebhookSecrets({ whatsapp: mod });
+    assert.equal(p.length, 1);
+    assert.match(p[0], /WHATSAPP_VERIFY_TOKEN/);
+  } finally { restore(); }
 });

← af93fb1 costa-rica: fail-closed boot guard — refuse prod boot if a l  ·  back to Costa Rica  ·  costa-rica: /yoloforever cycle 2 ledger — TK-10346 86510e3 →