[object Object]

← back to Costa Rica

costa-rica: fail-closed boot guard — refuse prod boot if a live integration lacks its webhook secret — TK-10346

af93fb11eb6987af9aa0b4e13a6060fc368c3f0c · 2026-08-08 08:14:33 -0700 · Steve

Closes a silent revenue-loss hole: if payment/WhatsApp is LIVE but the webhook secret is
missing, verifyWebhook returns ok:false for every real webhook -> charges succeed but bookings
never confirm, with no error. lib/preflight.js checks each provider at boot; in production it
THROWS (a live money system that can't verify webhooks must not serve), off-prod it only warns.
Providers expose webhookSecretSet. Inert while sandbox (verified: real modules -> problems:[] even
under NODE_ENV=production), so safe to ship to current sandbox prod. 8 regression tests; suite 109/109.

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

Files touched

Diff

commit af93fb11eb6987af9aa0b4e13a6060fc368c3f0c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Aug 8 08:14:33 2026 -0700

    costa-rica: fail-closed boot guard — refuse prod boot if a live integration lacks its webhook secret — TK-10346
    
    Closes a silent revenue-loss hole: if payment/WhatsApp is LIVE but the webhook secret is
    missing, verifyWebhook returns ok:false for every real webhook -> charges succeed but bookings
    never confirm, with no error. lib/preflight.js checks each provider at boot; in production it
    THROWS (a live money system that can't verify webhooks must not serve), off-prod it only warns.
    Providers expose webhookSecretSet. Inert while sandbox (verified: real modules -> problems:[] even
    under NODE_ENV=production), so safe to ship to current sandbox prod. 8 regression tests; suite 109/109.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/payments/onvo.js    |  2 +-
 lib/payments/tilopay.js |  2 +-
 lib/preflight.js        | 59 +++++++++++++++++++++++++++++++++++++++++++++++++
 lib/whatsapp.js         |  2 +-
 server.js               |  8 +++++++
 test/preflight.test.js  | 53 ++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 123 insertions(+), 3 deletions(-)

diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index b0aea2e..73beb93 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -64,4 +64,4 @@ function verifyWebhook(headers, rawBody) {
 }
 function safeParse(b) { try { return JSON.parse(b); } catch { return null; } }
 
-module.exports = { name: 'onvo', get liveMode() { return LIVE; }, createCharge, getCharge, refund, payout, verifyWebhook, mapStatus };
+module.exports = { name: 'onvo', get liveMode() { return LIVE; }, get webhookSecretSet() { return !!WEBHOOK_SECRET; }, createCharge, getCharge, refund, payout, verifyWebhook, mapStatus };
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
index 5b93f53..4b70954 100644
--- a/lib/payments/tilopay.js
+++ b/lib/payments/tilopay.js
@@ -114,4 +114,4 @@ function verifyWebhook(headers, rawBody) {
 
 function safeParse(b) { try { return JSON.parse(b); } catch { return null; } }
 
-module.exports = { name: 'tilopay', get liveMode() { return LIVE; }, createCharge, getCharge, refund, payout, verifyWebhook };
+module.exports = { name: 'tilopay', get liveMode() { return LIVE; }, get webhookSecretSet() { return !!WEBHOOK_SECRET; }, createCharge, getCharge, refund, payout, verifyWebhook };
diff --git a/lib/preflight.js b/lib/preflight.js
new file mode 100644
index 0000000..e773e1a
--- /dev/null
+++ b/lib/preflight.js
@@ -0,0 +1,59 @@
+'use strict';
+// TK-10346 — boot-time fail-closed guard against a silent revenue-loss misconfig.
+//
+// If a payment/WhatsApp integration is LIVE but its webhook secret is missing,
+// verifyWebhook returns { ok: false } for EVERY real webhook -> the charge succeeds
+// at the processor but our booking never transitions to confirmed. Payments happen,
+// bookings don't, and nothing errors loudly. This catches it at boot.
+//
+// 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.
+
+const SECRET_ENV = { tilopay: 'TILOPAY_WEBHOOK_SECRET', onvo: 'ONVO_WEBHOOK_SECRET' };
+
+// Pure — returns an array of human-readable problem strings (empty = all good).
+function checkWebhookSecrets({ getProvider, whatsapp } = {}) {
+  const problems = [];
+  if (getProvider) {
+    try {
+      const pay = getProvider();
+      if (pay && pay.liveMode && !pay.webhookSecretSet) {
+        const envName = SECRET_ENV[pay.name] || `${String(pay.name).toUpperCase()}_WEBHOOK_SECRET`;
+        problems.push(
+          `payment provider "${pay.name}" is LIVE but its webhook secret is missing ` +
+          `(set ${envName}) — real payment webhooks would be silently rejected and bookings never confirm.`
+        );
+      }
+    } catch (e) {
+      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.`
+    );
+  }
+  return problems;
+}
+
+// Side-effecting boot gate. Logs a loud banner; throws in production.
+function runPreflight(deps = {}) {
+  const env = deps.env || process.env;
+  const problems = checkWebhookSecrets(deps);
+  if (problems.length) {
+    const line = '═'.repeat(79);
+    console.error(
+      '\n' + line +
+      '\nFATAL PREFLIGHT — live integration without webhook verification:\n' +
+      problems.map((p) => '  • ' + p).join('\n') +
+      '\n' + line + '\n'
+    );
+    if ((env.NODE_ENV || '') === 'production') {
+      throw new Error('preflight failed: ' + problems.join(' | '));
+    }
+  }
+  return problems;
+}
+
+module.exports = { checkWebhookSecrets, runPreflight };
diff --git a/lib/whatsapp.js b/lib/whatsapp.js
index b129acc..cafff53 100644
--- a/lib/whatsapp.js
+++ b/lib/whatsapp.js
@@ -150,7 +150,7 @@ async function handleInbound(body) {
 }
 
 module.exports = {
-  get liveMode() { return LIVE; }, VERIFY_TOKEN,
+  get liveMode() { return LIVE; }, get webhookSecretSet() { return !!APP_SECRET; }, VERIFY_TOKEN,
   sendText, sendTemplate, sendButtons, sendList, sendImage, sendDocument, sendLocation, markRead,
   verifyChallenge, verifySignature, handleInbound, contactByWaId,
 };
diff --git a/server.js b/server.js
index 47e2507..e654090 100644
--- a/server.js
+++ b/server.js
@@ -691,6 +691,14 @@ app.use((req, res, next) => {
 
 app.use(express.static(path.join(__dirname, 'public')));
 
+// TK-10346 — fail-closed boot guard: refuse to serve in production if a payment/WhatsApp
+// integration is LIVE without its webhook secret (would silently reject every real webhook
+// -> payments succeed but bookings never confirm). Inert while everything is sandbox.
+require('./lib/preflight').runPreflight({
+  getProvider: require('./lib/payments').getProvider,
+  whatsapp: require('./lib/whatsapp'),
+});
+
 app.listen(PORT, '0.0.0.0', () => {
   console.log(`[${SITE_NAME}] listening on :${PORT} — gated as ${BA_USER || 'OPEN'} — domain ${SITE_DOMAIN}`);
 });
diff --git a/test/preflight.test.js b/test/preflight.test.js
new file mode 100644
index 0000000..34f13a2
--- /dev/null
+++ b/test/preflight.test.js
@@ -0,0 +1,53 @@
+'use strict';
+// TK-10346 — boot-time fail-closed guard for live-integration-without-webhook-secret.
+const { test } = require('node:test');
+const assert = require('node:assert');
+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 });
+
+test('checkWebhookSecrets: LIVE payment provider with NO webhook secret is flagged', () => {
+  const p = checkWebhookSecrets({ getProvider: () => liveNoSecretPay });
+  assert.equal(p.length, 1);
+  assert.match(p[0], /LIVE but its webhook secret is missing/);
+  assert.match(p[0], /TILOPAY_WEBHOOK_SECRET/);
+});
+
+test('checkWebhookSecrets: LIVE payment provider WITH a webhook secret is clean', () => {
+  assert.deepEqual(checkWebhookSecrets({ getProvider: () => liveWithSecretPay }), []);
+});
+
+test('checkWebhookSecrets: SANDBOX (not live) is clean even with no secret', () => {
+  assert.deepEqual(checkWebhookSecrets({ getProvider: () => sandboxPay, whatsapp: wa(false, false) }), []);
+});
+
+test('checkWebhookSecrets: onvo names its own secret env var', () => {
+  const p = checkWebhookSecrets({ getProvider: () => ({ name: 'onvo', liveMode: true, webhookSecretSet: false }) });
+  assert.match(p[0], /ONVO_WEBHOOK_SECRET/);
+});
+
+test('checkWebhookSecrets: LIVE WhatsApp with no APP_SECRET is flagged', () => {
+  const p = checkWebhookSecrets({ whatsapp: wa(true, false) });
+  assert.equal(p.length, 1);
+  assert.match(p[0], /WHATSAPP_APP_SECRET/);
+});
+
+test('runPreflight: THROWS in production when a live provider lacks its secret', () => {
+  assert.throws(
+    () => runPreflight({ getProvider: () => liveNoSecretPay, env: { NODE_ENV: 'production' } }),
+    /preflight failed/
+  );
+});
+
+test('runPreflight: dev/sandbox only WARNS (returns problems, does not throw)', () => {
+  let out;
+  assert.doesNotThrow(() => { out = runPreflight({ getProvider: () => liveNoSecretPay, env: { NODE_ENV: 'development' } }); });
+  assert.equal(out.length, 1); // problem surfaced (logged) but non-fatal off-prod
+});
+
+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' } }));
+});

← c67be91 costa-rica: /yoloforever cycle 1 ledger — TK-10346  ·  back to Costa Rica  ·  costa-rica: harden boot guard per Cody gate — VERIFY_TOKEN + ddf9318 →