[object Object]

← back to Costa Rica

costa-rica: Cody gate fixes (c1) — JWT_SECRET isolation+prod-required, host-listing IDOR guard, booking overlap guard, webhook sig crash-safe, fail-loud live ACH payout; tests 7/7 + IDOR/overlap E2E verified — TK-10346

b133da0b8ac2d1f0b9e10be1381d03719b05ab46 · 2026-08-07 10:09:21 -0700 · Steve

Files touched

Diff

commit b133da0b8ac2d1f0b9e10be1381d03719b05ab46
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 10:09:21 2026 -0700

    costa-rica: Cody gate fixes (c1) — JWT_SECRET isolation+prod-required, host-listing IDOR guard, booking overlap guard, webhook sig crash-safe, fail-loud live ACH payout; tests 7/7 + IDOR/overlap E2E verified — TK-10346
---
 lib/auth.js             |  8 +++++++-
 lib/payments/onvo.js    |  3 ++-
 lib/payments/tilopay.js |  3 ++-
 lib/payouts.js          |  6 ++++--
 routes/app.js           | 13 +++++++++++++
 5 files changed, 28 insertions(+), 5 deletions(-)

diff --git a/lib/auth.js b/lib/auth.js
index c8c0a6d..533e53f 100644
--- a/lib/auth.js
+++ b/lib/auth.js
@@ -3,7 +3,13 @@
 // Node's built-in crypto. Tokens carry { sub, role, host_id }.
 const crypto = require('crypto');
 
-const SECRET = process.env.JWT_SECRET || process.env.BASIC_AUTH_PASS || 'dev-insecure-secret-change-me';
+// NEVER reuse BASIC_AUTH_PASS (different security domain) or a hardcoded default.
+let SECRET = process.env.JWT_SECRET;
+if (!SECRET) {
+  if (process.env.NODE_ENV === 'production') throw new Error('JWT_SECRET must be set in production');
+  SECRET = crypto.randomBytes(32).toString('hex'); // ephemeral dev secret — tokens reset on restart
+  console.warn('[auth] JWT_SECRET unset — using an ephemeral dev secret (dev only).');
+}
 const TTL_SEC = parseInt(process.env.JWT_TTL_SEC || String(60 * 60 * 24 * 30), 10); // 30d
 
 const b64u = (buf) => Buffer.from(buf).toString('base64url');
diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index 633c14e..db5d5f9 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -50,7 +50,8 @@ function verifyWebhook(headers, rawBody) {
   if (!WEBHOOK_SECRET) return { ok: !LIVE, event: safeParse(rawBody) };
   const sig = headers['onvo-signature'] || '';
   const expect = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
-  const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
+  let ok = false;
+  try { ok = !!sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect)); } catch { ok = false; }
   return { ok, event: ok ? safeParse(rawBody) : null };
 }
 function safeParse(b) { try { return JSON.parse(b); } catch { return null; } }
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
index 10ae0c7..5b93f53 100644
--- a/lib/payments/tilopay.js
+++ b/lib/payments/tilopay.js
@@ -107,7 +107,8 @@ function verifyWebhook(headers, rawBody) {
   if (!WEBHOOK_SECRET) return { ok: !LIVE, event: safeParse(rawBody) }; // sandbox: accept
   const sig = headers['x-tilopay-signature'] || headers['tilopay-signature'] || '';
   const expect = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
-  const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
+  let ok = false;
+  try { ok = !!sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect)); } catch { ok = false; }
   return { ok, event: ok ? safeParse(rawBody) : null };
 }
 
diff --git a/lib/payouts.js b/lib/payouts.js
index b900dda..b1b93b7 100644
--- a/lib/payouts.js
+++ b/lib/payouts.js
@@ -36,8 +36,10 @@ async function createPayoutForBooking(bookingId) {
     if (rail === 'sinpe') {
       result = await provider.payout({ method: { sinpe_phone: pm.sinpe_phone, cr_iban: pm.cr_iban }, amount, currency, reference: b.code });
     } else {
-      // Plaid ACH payout — provider/ledger wiring pinned in go-live memo; sandbox records intent.
-      result = { providerRef: `ach_sbx_${payout.id}`, status: 'processing', raw: { rail: 'plaid_ach', sandbox: !provider.liveMode } };
+      // Plaid ACH payout not wired for real money yet — fail loud in live mode
+      // rather than silently record a fake success (Cody gate, TK-10346 c1).
+      if (provider.liveMode) throw new Error('plaid_ach payout not implemented — see go-live memo');
+      result = { providerRef: `ach_sbx_${payout.id}`, status: 'processing', raw: { rail: 'plaid_ach', sandbox: true } };
     }
     await pool.query(`UPDATE payouts SET provider_ref=$1, status=$2, raw=$3 WHERE id=$4`,
       [result.providerRef || null, result.status || 'processing', JSON.stringify(result.raw || {}), payout.id]);
diff --git a/routes/app.js b/routes/app.js
index eb9d472..1356ea4 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -108,6 +108,12 @@ router.post('/bookings', authRequired, async (req, res) => {
   if (pb.booking_type === 'nightly') {
     const n = nights(check_in, check_out);
     if (n < (pb.min_nights || 1)) return bad(res, 400, `min ${pb.min_nights} nights`);
+    // Overlap guard (Cody gate, TK-10346 c1). Robust fix = a btree_gist EXCLUDE
+    // constraint on daterange (queued in the go-live memo); this closes the common case.
+    const { rows: conflict } = await pool.query(
+      `SELECT 1 FROM bookings WHERE place_id=$1 AND status IN ('pending','confirmed','completed')
+         AND check_in < $3 AND check_out > $2 LIMIT 1`, [pb.place_id, check_in, check_out]);
+    if (conflict.length) return bad(res, 409, 'those dates are not available');
     subtotal = pb.base_price * n;
   } else {
     subtotal = pb.base_price * (guests || 1); // slot/ticket priced per guest
@@ -250,6 +256,13 @@ router.post('/host/listings', authRequired, async (req, res) => {
   const b = req.body || {};
   const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [b.place_slug]);
   if (!pl) return bad(res, 404, 'place not found');
+  // Ownership guard (Cody gate, TK-10346 c1): a host may only list a place they
+  // have claimed, and may not overwrite a listing owned by another host (IDOR).
+  const { rows: [guard] } = await pool.query(
+    `SELECT (SELECT 1 FROM place_hosts WHERE place_id=$1 AND host_id=$2) AS claimed,
+            (SELECT host_id FROM place_booking WHERE place_id=$1) AS current_host`, [pl.id, host.id]);
+  if (!guard.claimed) return bad(res, 403, 'claim this place first via /host/claim');
+  if (guard.current_host && guard.current_host !== host.id) return bad(res, 409, 'listing owned by another host');
   const { rows: [pb] } = await pool.query(
     `INSERT INTO place_booking (place_id, host_id, booking_type, currency, base_price, cleaning_fee, max_guests, min_nights, instant_book, is_active)
      VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE)

← 8c31834 costa-rica: marketplace test suite (money conservation, JWT,  ·  back to Costa Rica  ·  auto-data-snapshot: 2026-08-07T10:09:04 (2 data files) — dat 5d115e1 →