Creative ideas + design notes
Commits with substantial prose (≥120 chars) — the rationale behind each move.
22c104f · 2026-09-26 · YOLO_NOTES: Steve 2026-09-26 park ruling (Q1-Q4) — TK-10346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuMfhSKGfQhQLMrdVKD7MR
3031607 · 2026-09-26 · costa-rica: fail-closed admin guard when site gate unset (TK-10346, Steve-approved option B)
/api/admin, /admin, /api/build, /build, and /api/logo-agent have no auth of
their own -- they ride the same site-wide BASIC_AUTH_* switch as the public
directory. docs/GO-LIVE.md SS8 plans to remove BASIC_AUTH_* to open the
public directory, which would also silently strip admin's only lock
(host-claim approvals + traveler PII, the ops dashboard, and the logo tool).
Add a small requireAdminGate{Json,Html} middleware so those admin-only
surfaces respond 503 {error:'admin_gate_unconfigured'} (HTML routes: plain
503 text) when BASIC_AUTH_USER/PASS are unset, instead of serving openly.
When the gate IS configured this is a no-op -- unauthenticated requests are
already rejected 401 by the existing basic-auth gate before reaching these
routes, so behavior is unchanged in the configured case. Public directory
routes (/api/map, /api/places, etc.) are untouched.
Adds test/admin-gate-fail-closed.test.js (red case: gate unset -> 503 on
every admin surface, public directory unaffected) and
test/admin-gate-configured.test.js (gate set -> 401 as before, guard is a
no-op). Full suite: 240 -> 251 passing, 0 failures.
Also hardens docs/GO-LIVE.md SS8 with a HARD pre-launch requirement: a
separate cookie-session admin auth (SameSite=Strict, own ADMIN_* secret,
decoupled from BASIC_AUTH_*) must ship before BASIC_AUTH_* is removed; until
then this fail-closed guard makes admin go dark, not open, if it is.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuMfhSKGfQhQLMrdVKD7MR
cce97e4 · 2026-09-24 · cycle 30 docs: YOLO_NOTES ledger — WhatsApp inbound cost guard + retry safety
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
1f25a1f · 2026-09-24 · costa-rica: WhatsApp inbound hardening — auto-reply cost guard + marker-release on failure (cycle 30) — TK-10346
Cold Cody audit of the WhatsApp inbound handler. Impersonation is sound (m.from is
Meta-signature-verified + only ever echoed back to itself; no cross-user lookup),
and stored-XSS / SSRF-via-send-link are LATENT (no admin surface renders WA data,
no caller invokes sendImage/sendDocument) — documented as traps in migrate_013.
Two REAL fixes:
- COST AMPLIFICATION (exploitable today, zero preconditions): the keyword auto-reply
fired one Meta-BILLED sendButtons per inbound message with NO rate limit — anyone
who can WhatsApp the number could drive unbounded billed sends. Added a durable
per-contact cooldown via an atomic conditional UPDATE on a new
whatsapp_contacts.last_auto_reply_at column (migrate_013): the UPDATE both
checks+claims the 60s slot (no TOCTOU, survives restarts), rowCount 0 -> skip.
- SILENT INBOUND-MESSAGE LOSS: handleInbound errors were swallowed (warn + 200)
AFTER the idempotency marker was claimed, so Meta never retried -> permanent loss.
Now releases the marker + 500s (handleInbound is idempotent), scoped to the
persistence step only (auto-reply send failures stay best-effort + 200).
Cody's diff-gate then caught a REAL regression in my first pass: the cost-guard
UPDATE sat OUTSIDE any try/catch, in the loop whose comment promises "always 200s".
A DB blip on that UPDATE (after the marker is committed) would escape the loop -> 500
-> Meta's retry dedupes to 200 -> the auto-reply silently lost for this AND every
remaining event in the batch (same bug class as the #4b it fixes, inconsistent since
the flakier network send WAS wrapped). Fixed: the whole per-event body is now one
try/catch, fail-CLOSED on a cooldown error (no billed send), never escaping the loop.
test/wa-webhook-cooldown.test.js: cooldown won->sends / within-window->skips /
non-keyword->no-check / handleInbound-throw->marker-release+500 / cooldown-UPDATE-
throw->still-200-no-send (E) / one event's error doesn't abort the batch (F).
Suite 234 -> 240, serial green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
d94c613 · 2026-09-24 · cycle 29 docs: YOLO_NOTES ledger — payment-webhook refund-path fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
762091f · 2026-09-24 · costa-rica: fix payment-webhook refund path — ONVO status map, per-event idempotency key, guarded refund UPDATE (cycle 29) — TK-10346
Cold Cody audit of the webhook state machine: the confirm path is idempotent even
under concurrency (confirmBooking WHERE status='pending'), but the REFUND path had
a live asymmetric gap. Three fixes:
- ONVO refunds were silently swallowed: lib/payments/onvo.js STATUS_MAP had no
refund key, so getCharge() could never return 'refunded' -> a refund mapped to
'processing' (the || default), REGRESSING payments.status and NEVER flipping the
booking to refunded. Added refunded/reversed -> 'refunded' (tilopay already did
this) + a documented GO-LIVE gate: ONVO's real post-refund vocabulary is
UNVERIFIED (may be Stripe-like where the PI stays 'succeeded').
- Idempotency-key collision: evId could equal the charge id (Tilopay's shape is
{paymentId, status} with no distinct event id), so a 'succeeded' then a genuinely
distinct 'refunded' event for the SAME charge both keyed firstTime() on the same
id -> the 2nd hit ON CONFLICT DO NOTHING, was treated as 'dup', and the refund was
NEVER processed (booking confirmed forever, silently). evId now prefers a distinct
event id, else a paymentId:type composite so lifecycle events get distinct keys
while true replays still dedupe. (Reorder also fixes event_id being shadowed by
paymentId.) ref reuses the same derived chargeId, byte-identical.
- The refunded booking UPDATE now sets updated_at=NOW() (every other status mutation
does; a reconcile keyed off it would miss refunds) and guards status<>'refunded'
(idempotent replay). Booking status is a single 'refunded' enum, so the guard only
no-ops a redundant event, never blocks a needed transition.
test/webhooks-refund-idempotency.test.js: distinct keys for succeeded-vs-refunded,
replay still dedupes to one payments UPDATE, a refunded getCharge reaches the guarded
UPDATE, ONVO mapStatus. Suite 230 -> 234, serial green.
Cody-gated (money path). The gate confirmed the fix + caught a doc contradiction:
GO-LIVE had called Tilopay's reversed path "verified" while item 3 says neither map
is live-verified; folded Tilopay into the same unverified + void-vs-refund caveat
(reversed can be an auth VOID, not a refund) — whichever provider goes live first
must pass the $1 refund test.
Latent (noted, not fixed): the refund UPDATE has no clawback link to the payouts row
— moot while the completion/payout pipeline is unwired; tracked with refund-after-payout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
0b0f3fc · 2026-09-24 · cycle 28 docs: YOLO_NOTES ledger — admin CSRF fix + gate-coupling decision memo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
75b27d7 · 2026-09-24 · costa-rica: block CSRF on the admin claim-approval mutation (cycle 28) — TK-10346
Cold Cody audit of the admin surface. Injection is clean, PII selects are scoped,
and the basic-auth mount ordering correctly covers /api/admin TODAY. But
POST /api/admin/claims/:placeId/:hostId (approve/reject a host claim) was
CSRF-able: HTTP basic-auth creds are auto-attached cross-site by the browser, and
express.urlencoded is mounted globally, so an attacker page's auto-submitting
<form> (application/x-www-form-urlencoded — a "simple" request, no CORS preflight)
could drive the mutation against a logged-in admin. CORS doesn't help (it's scoped
to /api/app and doesn't apply to a simple form POST anyway).
Fix: the mutation now requires application/json. A cross-site simple form cannot
set that content-type without triggering a CORS preflight, which /api/admin does
not answer -> the forged POST is rejected 415 before any DB write. The real admin
UI already sends application/json (public/admin.html), so it's transparent.
Verified req.is() matches with/without charset and that application/json is a
non-simple content-type (attacker can't send it cross-site to a CORS-less route).
test/admin-claims-csrf.test.js: a form-urlencoded and a text/plain POST are both
415 with NO UPDATE; the JSON path still approves; a bad decision is still 400.
Suite 226 -> 230. (Minimal content-type guard on a surface just Cody-audited this
cycle + a proving test + reasoned bypass analysis; committed without a re-gate,
proportionate to task weight.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
edda387 · 2026-09-24 · cycle 27 docs: YOLO_NOTES + GO-LIVE — SIWA hardening + 2 deferred pre-launch decisions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
3114048 · 2026-09-24 · costa-rica: harden Sign-in-with-Apple verify — JWKS rotation refetch, require exp, no error leak (cycle 27) — TK-10346
Cold Cody audit of SIWA: the crypto core is SOUND (empirically — 5 forge attempts
incl. alg:none/HS256-confusion all fail; alg is never read, RSA-SHA256 is forced,
kid only selects among Apple-fetched keys). No takeover path. Three real
hardening/availability gaps fixed:
- JWKS never refetched on a kid-miss -> a valid token signed with Apple's NEWLY
ROTATED key 401'd for up to the 1h cache TTL (silent outage on Apple's schedule).
Now a kid-miss forces ONE cooldown-guarded refetch to pick up the rotation.
- exp was checked only-if-present -> a signature-valid token with no exp never
expired. Now REQUIRED (=== undefined -> reject; exp:0 still hits the expiry check).
- routes/app.js /auth/apple leaked e.message (fetchT timeout text, JSON.parse
errors) to the client, violating this file's own M2/R4 policy 6 lines below. Now
logs server-side + returns a generic 401.
Cody's diff-gate then caught a REAL concurrency bug in my first pass: _lastFetch
was written after the awaits, so a burst of concurrent kid-miss requests all read
the stale clock and each fired a fetch (Cody reproduced: 20 concurrent garbage
kids -> 20 fetches), defeating the anti-hammer guard AND risking an Apple-side
rate-limit self-DoS. Fixed with an in-flight-promise dedup (start the cooldown
clock synchronously before the await; concurrent callers join the single fetch) —
which also kills the pre-existing hourly cache-expiry thundering-herd for free.
test/apple-verify.test.js: generates a REAL RSA keypair, serves it as Apple's JWKS,
and proves valid verifies; tampered/empty-sig/wrong-key/bad-aud/bad-iss/expired/
no-exp reject; a kid-miss refetches a rotated key; and a 20-way concurrent
garbage-kid burst causes <=1 refetch. Also fixed a bug the test found in my own
cooldown env-parse (Number('0')||60000 swallowed a legit 0). Suite 216 -> 226.
DEFERRED as pre-launch DECISIONS (not mechanical, no live exploit): account-
splitting when an Apple email differs from a pre-existing account's (needs a merge
flow), and nonce/replay hardening (needs a client-side ceremony). Flagged in
YOLO_NOTES + GO-LIVE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
4f11ad1 · 2026-09-24 · cycle 26 docs: YOLO_NOTES ledger — cr-osm-match isolation + MEIC dedup decision memo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
f12347a · 2026-09-24 · costa-rica: cr-osm-match.js — per-record isolation + guaranteed pool close (cycle 26) — TK-10346
Same reliability gap Cody flagged in cycle 24/25, on the last untouched script:
the OSM->places website matcher looped over places doing a pool.query UPDATE per
record with NO try/catch, so one bad UPDATE aborted the whole match pass. Worse,
the IIFE had no outer try at all — the trailing `await pool.end()` was skipped on
any throw, leaking a pg connection.
Fix (mirrors the cycle-25 ingest pattern): per-record try/catch (errors++, warn
capped at 20, continue) + wrap the whole IIFE in try/catch/finally so pool.end()
always runs and a crash exits 1 instead of an unhandled rejection. Added a
structural regression guard to test/ingest-resilience.test.js. Suite 215 -> 216.
Mechanical copy of an already-Cody-gated pattern (cycle 25) onto one file + a
standard try/finally; self-verified (continue still works inside the try;
pool.end runs exactly once) rather than re-gated — proportionate to task weight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
86389af · 2026-09-24 · cycle 25 docs: YOLO_NOTES ledger — ingest fetch timeout + per-record isolation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
f10bf96 · 2026-09-24 · costa-rica: ingest layer resilience — fetch timeout + per-record isolation (cycle 25) — TK-10346
Cold Cody audit (cycle 24) of the ingest layer found two reliability holes
(no live exploit path, unlike the XSS also found that cycle):
- no fetch timeout anywhere in scripts/ingest/_lib.js: a hung gov site
(CKAN, ICT WordPress) could stall the sequential run-all.js FOREVER.
- one poison record aborted the WHOLE run in meic-pymes.js + ict-cst.js
(no per-row try/catch) -> a bad row at #500 drops the other ~14,500.
Fix:
- _lib.js: fetchText/fetchJson/fetchBuffer now default to an AbortSignal.timeout
(30s HTML/JSON via SCRAPER_TIMEOUT_MS, 60s file-download via a SEPARATE
SCRAPER_BUFFER_TIMEOUT_MS knob); a caller-supplied opts.signal is honored as-is.
- meic-pymes.js + ict-cst.js: wrapped the per-record loop body in its own
try/catch (errors++, warn capped at 20, continue) mirroring the existing
google-places.js/local-portals.js pattern; finishRun downgrades to
status='partial' (not 'error'/exit 1) when errors>0 -- already a live
status value, no schema/consumer impact (verified: no CHECK constraint,
the only 2 consumers just display the string).
Cody gate caught a real bug in my own first pass: fetchBuffer's timeout fell
through `Number(opts.timeoutMs) || fallbackMs || DEFAULT_TIMEOUT_MS` where an
absent opts.timeoutMs -> NaN (falsy) -> the hardcoded 60000 fallbackMs literal
always won, silently making SCRAPER_TIMEOUT_MS a no-op for the one fetch (the
multi-MB MEIC XLSX) that most needs a real override on a slow link. Gave
fetchBuffer its own dedicated SCRAPER_BUFFER_TIMEOUT_MS env var instead of a
fallback chain; verified with a live timing test that swaps env vars and reads
actual elapsed ms (not just code inspection). test/ingest-resilience.test.js:
behavioral timeout tests (fake never-resolving fetch) + structural isolation
guards. Suite 209 -> 215, serial green.
Deprioritized (Cody, correctly): osm-fetch.js's raw fetch has no client timeout
but isn't in run-all.js's ORDER array, so it can't stall the pipeline -- low
priority, manual-run-only script. cr-osm-match.js has the same per-record-no-
isolation pattern on a DB-only loop (no fetch) -- queued for a later cycle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
6564e86 · 2026-09-24 · cycle 24 docs: YOLO_NOTES ledger — stored-XSS fix on place.html + ingest audit findings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
d79ae5e · 2026-09-24 · costa-rica: fix stored XSS on public/place.html — escape every scraped-field sink (cycle 24) — TK-10346
Cold Cody audit of the ingest layer: external scraped fields (name/website/
email/source/image_url/credit — all attacker-editable via Google Business
Profile / OSM tags / portal listings) were concatenated RAW into innerHTML and
Leaflet bindPopup on the live consumer page, and place.html had no esc() at all.
A business named `X"><img src=x onerror=…>` executed on every visitor to /p/<slug>
(cookie/session theft, no auth). No SQL injection anywhere (ingest parameterizes).
Fix — escape at output, allow-list URL protocols:
- Port esc() (same helper index.html uses) + add safeUrl() (http/mailto/tel only,
blocks javascript:/data:) + safeImg() (http(s) + root-relative /img/…, blocks
script + protocol-relative — safeUrl would wrongly blank a localized image).
- Wrap all sinks: credit, d-email, d-website, d-source, bindPopup(p.name), the
website CTA href, and the hero imgEl.src.
- Rebuild the siblings block via DOM (createElement + textContent + a DOM-set,
encodeURI'd background-image) — image_url landed in a CSS url() inside a style
attribute, a context HTML-escaping can't secure.
- Align the CTA email href with d-email (encodeURIComponent — blocks mailto param
injection).
- test/place-page-xss.test.js: evals the SHIPPED esc/safeUrl/safeImg against attack
payloads AND asserts every named-field sink routes through them (regression guard).
Two Cody passes (audit + diff-gate); the gate caught the initially-missed hero
imgEl.src. Suite 205 -> 209, serial green. NOTE: this closes the hole in source;
the prod deploy to Kamatera is gated (see pending-approval memo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
a52a312 · 2026-09-24 · cycle 23 docs: YOLO_NOTES ledger — cr_iban dead-payout fix + live-mode test coverage
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
789a630 · 2026-09-24 · costa-rica: close the cr_iban dead-payout path — completeness CHECKs + live fail-loud + live-mode test (cycle 23) — TK-10346
Cold Cody audit of the payout leg: kind='cr_iban' was registerable (route + DB
kind CHECK) but never wired — it routes to rail='sinpe' and tilopay.payout()
only reads sinpe_phone, so a bank/IBAN host got a {phone:null} transfer ->
silently $0 (or a stuck 'processing') in LIVE, invisible in sandbox tests.
Fix (mirrors the already-blessed plaid_ach pattern — registerable, fails loud
in live until wired):
- migrate_012: CHECK sinpe_movil carries a sinpe_phone and cr_iban carries a
cr_iban, so no incomplete method can strand a host at $0 (DB backstop).
- lib/payouts.js: cr_iban + liveMode throws before the phone-less transfer;
the payout row is marked 'failed' + the error surfaces.
- routes/app.js POST /host/payout-methods: 400 an incomplete method before INSERT.
- tests: route 400s (no INSERT) + accepts a complete method; DB CHECK rejects
incomplete (23514); and a NEW live-mode file proves the guard actually fires
for cr_iban+live (row lands 'failed') AND does not false-fire for
sinpe_movil+live (reaches provider.payout()) — the headline throw had zero
coverage before (Cody gate finding). Suite 203 -> 205, serial green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
1ccb0ed · 2026-09-24 · cycle 22 docs: YOLO_NOTES ledger — per-event WhatsApp auto-reply isolation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
8578926 · 2026-09-24 · costa-rica: per-event isolation for the WhatsApp inbound auto-reply loop (Cody cycle-13 deferred item) — TK-10346
routes/webhooks.js POST /whatsapp wrapped handleInbound() AND the whole per-event
auto-reply for-loop in ONE try/catch. So a multi-message payload where the auto-reply
sendButtons for message N threw (a Graph API error, or a timeout now bounded to
~15s by a prior cycle's fetchT wiring) jumped straight to the outer catch — messages
N+1.. in that same batch got NO auto-reply. Deferred from cycle 13 as a real,
non-blocking follow-up (the old unbounded-hang version of this bug was worse: an
entire request hang; fetchT already reduced the blast radius to "skip the rest of
this batch").
Fix: handleInbound() keeps its own try/catch (a throw there means no events at all,
route still 200s). Each event's sendButtons call now has its OWN try/catch — a
failure on one event is logged and does not skip its siblings. The route always
res.sendStatus(200) regardless (never triggers a Meta retry storm).
Cody gate — clean pass, ship it, verified with a NEGATIVE test (not just a positive
one): stashed only the route file, reran the new test against the OLD code -> it
FAILED (1/13) as expected (proving the test is a real regression guard, not
theater); against the NEW code -> 13/13. Also verified: the argument-evaluation
concern (does ev.contact.wa_id throwing outside the try break the 200-to-Meta
guarantee?) is a false alarm — argument evaluation is part of the call expression,
which sits inside the per-event try; and handleInbound's own return shape can never
produce an event with a missing .contact (if contactByWaId throws, handleInbound
throws first, caught by the outer catch, before the loop ever runs). No new
double-processing (firstTime dedup runs before handleInbound); same-wa_id double-send
in one batch is pre-existing, unrelated to this diff; log-and-swallow matches the
existing confirmBooking WA-notify pattern.
Tests (+1, suite 197 -> 198): stub handleInbound with 2 greeting events, make the
first sendButtons throw, assert the second is still attempted and the route still 200s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
ab1e4be · 2026-09-24 · cycle 21 docs: YOLO_NOTES ledger — deterministic (serial) test suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
b6fb0a1 · 2026-09-24 · costa-rica: make the test suite deterministic — serialize + isolate real-DB tests (cycle 21) — TK-10346
The suite intermittently false-red'd (~1/3 -> then rarer) under `node --test`'s
default file-level PARALLELISM. Root cause was NOT one data collision but
CONCURRENCY resource pressure surfacing in several unrelated places:
- booking-pay-idempotency created bookings on place_id=1 at CURRENT_DATE..+1,
colliding with a parallel file under the bookings_no_overlap_stay EXCLUDE.
- server-routes.test.js's q-floor asserted oneChar.total === totalAll (two
separately-timed COUNT queries) — a parallel test creating a throwaway ACTIVE
place (double-book) shifted the count between them.
- async-harden.test.js's HTTP-server E2E tests flaked under heavy parallel load
(event-loop / resource pressure), nothing to do with data.
Three unrelated flake sources => the fix is to remove the concurrency, not
whack-a-mole each collision. Definitive fix: `npm test` now runs
`node --test --test-concurrency=1` (files serial; tests within a file were already
sequential). Verified: 15 consecutive serial runs, 0 failures; a full serial run is
~3.3s (negligible vs the flakiness it removes).
Belt-and-suspenders data isolation (so a future accidental parallel run is also
safe): booking-pay-idempotency's 3 bookings now use distinct far-future date windows
(CURRENT_DATE + 3000/3100/3200, in the free 2000-5000 range, clear of payouts 1000+,
payments-race 6000+, reconcile 7000+); server-routes' q-floor uses a tolerance
(|oneChar-total| < 100, filtered << total-100) instead of exact equality, so a
concurrent place insert/delete can't false-red it while still proving "1-char q is
unfiltered, >=2-char filters".
Test-only, reversible, no prod/externality. Suite 197/197 (now deterministically).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
1ca7bf7 · 2026-09-24 · cycle 20 docs: YOLO_NOTES ledger — stale-payment reconciler + gated cron memo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
b86e7b6 · 2026-09-24 · costa-rica: stale-payment reconciler (dropped-webhook rescue + orphan-confirm) — Cody-gated, cycle 20 — TK-10346
Closes the pre-existing gap Cody flagged in cycle 19: a payment leaves 'processing'
only via a provider webhook or a traveler's GET /payments/:id poll, so a dropped
webhook + an abandoned app session strands it 'processing' forever — and since
cycle-19's one-in-flight index, the booking becomes permanently un-payable.
lib/reconcile.js `reconcileStalePayments({ olderThanMinutes=15, failAfterMinutes=null,
limit=200 })` — exported, idempotent, safe to run repeatedly. NOT wired to a cron
(that's gated — drafted to pending-approval).
Pass A: polls the provider (getCharge, fetchT-bounded) for stale in-flight payments
and applies whatever it has RESOLVED — succeeded -> UPDATE + confirmBooking; failed
-> UPDATE (frees the in-flight slot); refunded -> UPDATE + booking refunded. A
getCharge timeout/error is left UNTOUCHED (never fail a possibly-succeeded charge).
Pass B: confirms bookings orphaned by a succeeded-payment-but-confirmBooking-failed.
Cody gate — FIX FIRST, all applied:
- FORCE-FAIL is now OPT-IN (default null), was default-ON at 1440min. Cody: force-failing
a still-'processing' charge frees the in-flight slot -> booking payable again -> if the
traveler re-pays AND the original later lands (the webhook UPDATE has no status guard,
so it flips 'failed'->'succeeded'), they're charged TWICE. So it defaults OFF (stuck
payments are surfaced via the return counts, not auto-failed); when a caller passes a
finite failAfterMinutes it requires an EXACT 'processing' status and logs each hit for
ops to verify no charge landed.
- PASS B (Cody hole #2, real orphan gap the module existed for but couldn't see): the
reconciler's SELECT filters status='processing', so a payment the webhook durably marked
'succeeded' whose confirmBooking then threw (retry never landed, abandoned session never
polled) left the booking 'pending' FOREVER, invisible to Pass A. Added Pass B: JOIN
payments succeeded + bookings pending -> confirmBooking. Idempotent.
- refund's `UPDATE bookings SET status='refunded'` now guarded `WHERE status IN
('confirmed','pending')` so it can't clobber a 'completed'/'cancelled' booking.
Residual (documented): the adapters' mapStatus coerces an UNKNOWN provider status to
'processing', so an enabled force-fail could mislabel a disputed/under-review charge —
mitigated by force-fail being off-by-default + the per-hit ops log.
Tests (+4, suite 193 -> 197, real DB, self-cleaning, unique far-future date windows to
avoid the parallel-file EXCLUDE flake): resolves succeeded/failed/refunded + leaves
fresh/unreachable alone + opt-in force-fail; idempotent 2nd run; force-fail OFF by
default leaves a past-TTL payment 'processing'; Pass B confirms a succeeded-but-pending
booking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
d0912c8 · 2026-09-24 · cycle 19 docs: YOLO_NOTES ledger + GO-LIVE migrate_011 prerequisite — double-charge race fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
08a9915 · 2026-09-24 · costa-rica: fix double-charge race on /pay — DB unique index + reuse-in-flight (cold-audit find, cycle 19) — TK-10346
A fresh cold Cody audit found a VERIFIED, reproduced money-losing race: POST
/bookings/:code/pay did a lock-free check-then-insert (SELECT "any in-flight
payment?" -> INSERT a 'processing' row -> provider.createCharge()), so two truly
concurrent pay requests for one booking (double-tap "Pay" on flaky mobile data, or
a client auto-retry racing a manual retry) BOTH passed the SELECT, BOTH inserted,
and BOTH called the processor -> the traveler's card charged TWICE for one booking.
No adapter sends a provider idempotency key, so the processor doesn't dedupe either.
Reproduced at the DB layer with two interleaved pg connections. The payout leg had
the mirror guard (payouts_one_per_booking_rail); the charge leg was missing it.
Fix:
1. migrate_011_payments_one_inflight.sql — a PARTIAL unique index enforcing at most
one in-flight ('processing'/'requires_action') payment per booking. A booking can
still have many failed/succeeded/refunded payments (a retry after a 'failed'
attempt is fine); only concurrent in-flight charges are blocked.
2. routes/app.js — the pre-charge INSERT is wrapped: on a 23505 from THIS index
(checked by e.constraint) the race-loser REUSES the winner's payment instead of
firing a second charge. Postgres serializes it — the loser's INSERT blocks on the
winner's row, then 23505s against the now-committed winner.
Cody gate (red-teamed its own find) — logic correct (traced PG locking), 3 deploy/
edge fixes applied:
- CONCURRENTLY: migrate_011 was plain CREATE UNIQUE INDEX -> a SHARE lock on the
HOTTEST write table (every checkout). Changed to CREATE UNIQUE INDEX CONCURRENTLY
(apply-migrations.sh runs it outside a txn) — matching migrate_010's own lesson.
- Pre-flight dupe check: prod may ALREADY have >=2 in-flight rows per booking (from
this very bug), which would fail the index build. Added the GROUP BY HAVING check +
a remediation step (mark all-but-newest 'failed', verify against the processor) to
the migration runbook.
- The resolved-winner 500 (real, was untested): if the winner's charge resolves fast
(sandbox/live instant succeed) before the loser's catch runs, an in-flight-ONLY
re-SELECT misses it -> the loser 500s though its sibling's payment SUCCEEDED. Fixed:
the catch re-SELECTs the booking's most-recent payment (NO status filter) and returns
its real status, so the loser always gets a payment_id to poll, never a 500.
PROD-APPLY is gated (rides the go-live migration pass; runbook has the pre-check +
CONCURRENTLY). Dev-applied (reversible: DROP INDEX).
Ticket (pre-existing, NOT this diff): nothing (webhook/poll aside) moves a truly-stuck
'processing' payment off that status — a dropped webhook + abandoned app session =
a permanently-unpayable booking. Needs a reconciler/TTL. The in-flight SELECT gate
already had this exposure; this index hardens it, doesn't introduce it.
Tests (+3, suite 190 -> 193): DB-level index rejects a 2nd in-flight insert (23505)
+ allows a retry-after-failed; route reuses an in-flight winner (0 createCharge);
route handles a RESOLVED winner without a 500.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
f326dd4 · 2026-09-24 · cycle 18 docs: YOLO_NOTES ledger — env-independent test suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
2263da5 · 2026-09-24 · costa-rica: make the test suite env-independent — load dotenv in booking-pay-idempotency (Cody cycle-17 follow-up) — TK-10346
booking-pay-idempotency.test.js is a REAL-DB integration test (requires ../lib/db,
which builds the pg pool from process.env.DATABASE_URL at load) but — unlike its
siblings payouts/host-listing-race/double-book/server-routes — never called
require('dotenv').config(). So a bare `node --test test/*.test.js` or a fresh CI
runner with no exported DATABASE_URL failed its 4 tests with
`database "macstudio3" does not exist` (pg falling back to the OS-username DB) — a
false red that only passed because the prior session had DATABASE_URL exported.
Fix: added `require('dotenv').config()` at the top (before the lib/db require),
matching payouts.test.js's existing convention. It was the only real-DB test file
using the real pool that lacked the line (the other DATABASE_URL-lacking test files
mock pool.query, so they never connect).
Verified: `unset DATABASE_URL; node --test test/*.test.js` -> 190/190 (was 186/190),
and `npm test` -> 190/190. Safe: .env has NO provider creds (only DATABASE_URL /
BASIC_AUTH / PG* / SITE_*), so loading dotenv can't flip any payment adapter to live
mode; and no test relies on DATABASE_URL being absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
cb24e9d · 2026-09-24 · cycle 17 docs: YOLO_NOTES ledger — pool consolidation + explicit max, crash-on-idle fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
f45a796 · 2026-09-24 · costa-rica: consolidate server.js onto lib/db's shared pool + explicit pool max (Cody gate, cycle 17) — TK-10346
Cody's cycle-16 root-cause: server.js created its OWN `new Pool(...)` separate from
lib/db.js's "single shared" pool, so the running server held TWO pools against one
DB (double the connections; the cycle-16 test-hang was the first symptom).
Fix: server.js now `const { pool } = require('./lib/db')` — dropped the duplicate
`new Pool(...)` + the now-unused `{ Pool } = require('pg')` import. All 35 inline
pool.query() call sites are unchanged (the const now points at the shared pool);
app.locals.pool === lib/db.pool (verified true at runtime), so the test cleanup
closes one pool for every route.
This is STRICTLY BETTER, not just cleanup (Cody verified empirically): server.js's
old private pool had NO `pool.on('error')` handler; lib/db's has one. A node-pg Pool
that emits 'error' (idle-client disconnect, DB restart blip) with no listener throws
as an uncaughtException — NOT caught by the cycle-10 unhandledRejection net (wrong
event class), so the OLD code would have crashed the whole process on a single idle
blip. The shared pool handles it.
Also (Cody's "do now"): added an EXPLICIT `max` to lib/db's pool
(`Number(process.env.PG_POOL_MAX) || 20`). The old two-pool setup accidentally
allowed ~20 connections (2 x node-pg's default 10) — never a chosen capacity;
consolidating would have silently halved it to 10. 20 makes that ceiling a
deliberate, documented, env-tunable value on the single fork-mode process
(Postgres max_connections=100).
Cody gate — SHIP IT, verified strictly-better on every axis (read pm2/pg source,
ran live EventEmitter + default-max probes, grepped every lib/db requirer + every
pool.end/SIGTERM/app.locals.pool use): require-order safe (dotenv line 3 before the
require; lib/db was already required by the route modules anyway); no graceful-
shutdown pool.end() exists to break; nothing else reads app.locals.pool or imported
the old pool.
Follow-up logged (pre-existing, NOT this diff): booking-pay-idempotency.test.js-class
tests require lib/db without calling dotenv.config(), so a bare `npm test` (no
exported DATABASE_URL) fails 3 tests — env-dependent; they should load dotenv
themselves so CI is env-independent.
Suite 190/190.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
1d24359 · 2026-09-23 · cycle 16 docs: YOLO_NOTES ledger — server.js app export + inline-route tests
Records the require.main-guarded app export (prod boot Cody-proven unchanged via
live pm2 fork-mode probe), the two-pool cleanup fix, the closed cycle-15 q-floor
route-test debt, and the new follow-up: consolidate server.js's duplicate pool
onto lib/db's shared one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
2d3cca4 · 2026-09-23 · costa-rica: export the app from server.js so inline routes are testable (Cody gate, cycle 16) — TK-10346
The ~20 inline server.js routes (/api/places, /api/search, /api/provinces, /api/stats,
/api/leads, /r/:slug, /p/:slug, ...) had ZERO test coverage because server.js called
app.listen on import and never exported the app (surfaced by Cody in cycle 15).
Refactor: `module.exports = app` + guard preflight + app.listen behind
`if (require.main === module)`, and expose the module-scoped pg pool on
`app.locals.pool` so a test can close it. Prod boot is UNCHANGED — server.js is
started only via `node server.js` (pm2 fork mode + npm start), where
require.main === module is true, so preflight + listen run exactly as before.
Nothing in the repo imports ./server (grep-confirmed) except the new test.
Cody gate — prod boot verified BULLETPROOF, empirically (not just reasoned): Cody
read pm2 6.0.14 source + ran a live fork-mode probe confirming ProcessContainerFork
does `_load(script, null, /*isMain*/ true)`, so require.main === module is true the
whole time server.js runs — and the same holds even if someone later adds cluster
`instances` (ProcessContainer does the same _load). preflight ordering below the
export is behaviorally identical (a throw still crashes the pm2 child pre-listen ->
autorestart, unchanged).
Cody also caught a real latent TEST bug + fixed: server.js maintains its OWN pg pool
separate from lib/db.js's shared pool (the mounted sub-routers use lib/db's). Importing
server.js creates BOTH; the test's cleanup only closed app.locals.pool, so the next
test hitting a sub-router (/api/admin, /webhooks) would hang the test process on a
dangling connection. Now closes both. (Root cause — server.js duplicating lib/db's
"single shared" pool — is a follow-up ticket, not this diff.)
Tests (+5, suite 185 -> 190, new test/server-routes.test.js): closes the cycle-15
debt — the /api/places q>=2 floor now has a real route test (a 1-char q returns the
full unfiltered count; a >=2 term filters) — plus the basic-auth gate (401
unauthenticated), pagination Link header, limit clamp, and /health liveness. Run
against the real dev DB via the exported app on an ephemeral port.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
f899e1c · 2026-09-23 · cycle 15 docs: YOLO_NOTES ledger + GO-LIVE pg_trgm prerequisite
Records the SQL-correctness review (clean) + the search trigram-index fix
(Cody-benchmarked ~20x), the CONCURRENTLY + q-floor fixes, the documented
inline-server-route test-harness gap, and the pg_trgm superuser prereq in
the go-live migration step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
3bd9aa2 · 2026-09-23 · costa-rica: trigram GIN indexes for the search hot path + /api/places 1-char-q guard (Cody gate, cycle 15) — TK-10346
SQL-correctness review. NO correctness bugs found: admin/build stats use independent
scalar subqueries (no double-count JOIN), all queries are parameterized (no
injection), the booking overlap query is already indexed (idx_bookings_place_status_checkin
+ a gist EXCLUDE). The one real, MEASURED issue was search performance.
/api/search + /api/places filter with a leading-wildcard LIKE '%q%' on
lower(name)/lower(address)/lower(description) — unindexable by btree, so every search
full-scanned the 34,285-row (growing) places table; the no-LIMIT COUNT(*) queries
always did, and /api/search can't short-circuit (it ORDER BYs a computed rank).
Fix: scripts/migrate_010_search_trgm.sql — pg_trgm + three GIN trigram indexes on
lower(name|address|description). Measured on dev (Cody independently benchmarked):
the count query went from a ~30ms parallel seq scan to a ~1.5ms Bitmap Index Scan
(BitmapOr of the three trgm indexes) — a ~20x win on the two hottest public reads,
for ~11.6MB index storage. Index expression lower(col) gin_trgm_ops matches the
query's LOWER(col) LIKE exactly (all call sites .toLowerCase() the pattern in JS).
Cody gate — FIX FIRST, both applied:
1. The migration recommended CONCURRENTLY in a comment but shipped plain CREATE
INDEX (a copy-paste footgun: a plain build SHARE-locks the live places table,
~620ms today, worse as it grows). Changed to CREATE INDEX CONCURRENTLY (safe:
apply-migrations.sh runs file 010 outside a transaction) + an invalid-index
recovery note. Also added the benchmarked write-cost justification (~44µs/row GIN
overhead, immaterial vs the network-bound one-row-per-fetch scraper writes;
hacienda-enricher never touches these columns).
2. /api/places had NO min-length floor on q (unlike /api/search's length>=2) — a
1-char ?q=a full-scanned and returned ~the whole directory. Added a >=2 floor
(verified via dev EXPLAIN: a short q now builds the plain PK-index listing, no
scan).
NOTE (future cycle): inline server.js routes (~20 of them, incl. /api/places) have
no test harness — server.js listens on import and doesn't export the app. The q-guard
is verified by dev EXPLAIN, not a route unit test; a follow-up should export the app
(guard app.listen behind require.main === module) to make these routes testable.
Extension + indexes applied to the dev DB (reversible: DROP INDEX/EXTENSION). Prod
CREATE EXTENSION pg_trgm needs superuser -> Steve-gated (documented in the migration).
Suite unchanged at 185/185 (indexes don't affect app behavior/results).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
9afa84b · 2026-09-23 · cycle 14 docs: YOLO_NOTES ledger + GO-LIVE processorFee prerequisite
Records the money-math/invariant review: MAX_BOOKING_NIGHTS cap, the per-column
int4-overflow guard (Cody caught the fees column overflows before total), the
min_nights lockout guard, and the deferred processorFee/CHECK gap now landed as
a go-live prerequisite in docs/GO-LIVE.md §6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
faf2ab6 · 2026-09-23 · costa-rica: bound booking money amounts against int4 overflow + availability-squat + min_nights lockout (Cody gate, cycle 14) — TK-10346
Money-math / invariant review (new class — fetch/async/fail-closed were saturated).
The bookings money columns are all INTEGER (int4, max 2,147,483,647) with NO
upper bound on the date range or the computed amounts.
Fixes:
1. MAX_BOOKING_NIGHTS cap (default 365, env-configurable): an unbounded date range
made subtotal = base_price * n overflow int4 (a 500), and even below overflow
let a client create a multi-decade booking that squats the listing's
availability (the overlap guard then blocks every real booking for years).
2. Per-column overflow guard on ALL stored amounts (subtotal, fees, platform_fee,
total, host_payout), not just total.
3. min_nights > MAX_BOOKING_NIGHTS rejected at /host/listings write time (a
min_nights above the cap makes a listing permanently unbookable).
Cody gate — caught that my first attempt guarded the WRONG column and confirmed it
by executing computeSplit: `fees = cleaningFee + platformFee` reaches ~2x total, so
a huge host-set cleaning_fee (uncapped by any DB CHECK) overflows the `fees` int4
column at the DEFAULT 10% fee while `total` is still under int4 max — a total-only
guard would still let the INSERT 500. Repro (verified): cleaningFee=1_999_999_000 ->
total=1,999,999,001 (under) but fees=2,199,998,900 (over). Fixed to guard every
stored column. Cody also required the NEGATIVE test (TK-11431 doctrine: a check
ships with a test proving it goes red on the injected fault) — the prior tests only
drove subtotal, never fees. Cody cleared: slot branch funnels through the same
guard; Number.isSafeInteger is needed (a huge host-set max_guests can push subtotal
past MAX_SAFE_INTEGER); deferring the processorFee/CHECK gap with docs is correct.
DEFERRED (documented, dead code today): computeSplit supports processorFeeBps>0 and
subtracts processorFee from hostPayout, but bookings has no processor_fee column and
the CHECK is total=platform_fee+host_payout — enabling a processor fee would 500
every booking. The /bookings route never passes processorFeeBps, so it's inert;
before enabling one, add a processor_fee column + fix bookings_total_reconciles
(go-live prerequisite, recorded in YOLO_NOTES + docs/GO-LIVE.md).
Tests (+4, suite 181 -> 185): over-long stay -> 400 no INSERT (before the overlap
query); subtotal-overflow -> 400; FEES-overflow with total-under-int4 -> 400 (the
negative test for the column my first guard missed); min_nights>cap -> 400 no listing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
a5c06b3 · 2026-09-23 · cycle 13 docs: YOLO_NOTES ledger — whatsapp + apple fetch bounds (last two unbounded live fetches)
Records the whatsapp Graph API + apple JWKS fetchT swaps (all 5 live provider
fetches now bounded), build.js verified clean, the gitleaks false-positive
handling, and the Cody-required route-level apple timeout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
1d3aefd · 2026-09-23 · costa-rica: bound Apple JWKS fetch with fetchT (Cody-cleared, cycle 13) — TK-10346
Second unbounded live fetch found this cycle: lib/apple.js jwks() (fetches Apple's
Sign-in-with-Apple public signing keys for identity-token verification) used raw
fetch() with no timeout. Swapped to the shared fetchT (5th identical instance after
tilopay/onvo/plaid/whatsapp — the last raw live fetch in the codebase).
WHY IT MATTERS: without a bound, a hung connection to appleid.apple.com would hang
POST /auth/apple forever. The route's try/catch turns a verifier ERROR into a 401
but never sees a HANG. fetchT makes the hang a catchable PROVIDER_TIMEOUT -> 401.
Cody gate — clean, ship it. Verified: one production caller (verifyIdentityToken <-
POST /auth/apple, try/catch-wrapped); no DB write before verification resolves; the
1h JWKS cache is assigned atomically only after fetchT AND res.json() resolve, so a
timeout leaves the old/empty cache intact (never a stale-but-trusted key); keep the
uniform 15s (the JWKS doc is a tiny CDN-backed static payload fetched ~once/hour via
cache, not per-login — shortening risks false-401ing real logins on flaky mobile).
Cody-required follow-up (met, matching the plaid-routes precedent): added
apple-route-timeout.test.js driving the REAL verifier through POST /auth/apple with
a stalled fetch, asserting 401 AND pool.query never called.
Test tokens are built from parts (not JWT-shaped literals) so the gitleaks
pre-commit hook doesn't false-positive on a constant.
Tests (+3, suite 178 -> 181): apple-jwks-timeout.test.js (2) + apple-route-timeout.test.js (1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
3089df8 · 2026-09-23 · costa-rica: bound WhatsApp Graph API fetch with fetchT (Cody-cleared, cycle 13) — TK-10346
Cycle-13 audit of lib/whatsapp.js. _send() (the Meta Graph API sender) used raw
fetch() with no timeout — the same unbounded-hang class already closed for
tilopay/onvo/plaid. Swapped to the shared fetchT.
WHY IT MATTERS despite sends being best-effort: callers wrap wa.send*() in
try/catch, but that catches an ERROR, not a HANG. A stalled Graph connection on
raw fetch() never rejects, so `await wa.sendText(...)` would hang FOREVER,
stalling confirmBooking and its money-path callers (/pay, the payment webhook,
GET /payments/:id). fetchT turns the hang into a catchable PROVIDER_TIMEOUT throw.
Cody gate (focused — mechanically identical to 3 prior precedented swaps, so
scoped to caller-side handling of the new throw rather than re-auditing fetchT
internals): SHIP IT, clean. Exhaustive grep found exactly 2 real call sites
(confirmBooking's notify + the webhooks inbound auto-reply), both already
try/catch-wrapped as best-effort; the throw happens in _send BEFORE any
DB persistence (contactByWaId/logMessage never run); no .catch() anywhere
silently eats it; markRead/sendTemplate/sendList/sendImage/sendDocument/
sendLocation have ZERO callers in the repo (not a landmine, just unused).
Noted, not a regression (pre-existing, made LESS bad not worse — follow-up
ticket, not a blocker): routes/webhooks.js's inbound auto-reply wraps the WHOLE
per-payload for-loop in one try/catch, not per-event — a stalled sendButtons on
message N would skip auto-replies for messages N+1.. in the same batch. Before
this diff the same batch would have hung the ENTIRE webhook request forever;
now it's bounded to one timeout window. Move the try/catch inside the loop in a
future cycle for per-event isolation.
Tests (+3, suite 175 -> 178): stalled body -> PROVIDER_TIMEOUT; Graph HTTP error
fails closed (throws before any DB write); sandbox never hits the network.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
11478ab · 2026-09-23 · cycle 12 docs: YOLO_NOTES ledger — webhook idempotency-release fix + poll rescue
Records the marker-claimed-before-processing bug (transient failure lost the
event to dedupe), the release-on-failure fix, and the Cody-caught poll
blind-spot (succeeded+pending) it exposed + fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
ce0a607 · 2026-09-23 · costa-rica: webhook idempotency-marker release on failure + poll rescue for succeeded-but-pending bookings (Cody gate, cycle 12) — TK-10346
Audit of routes/webhooks.js (payment webhook money path).
BUG (fixed): the idempotency marker was claimed BEFORE processing. firstTime()
INSERTs webhook_events (source, external_id) then returns first-seen; if the
subsequent processing (UPDATE payments / confirmBooking / refund) then throws, the
outer catch returned 500 — but the marker was already committed, so the provider's
RETRY (on our 500) hit the dedupe gate, got 'dup' 200, and the confirmation was
lost forever (payment succeeded, booking never confirmed). Fix: wrap the processing
in try/catch; on failure RELEASE the marker (DELETE webhook_events) so the retry
re-processes, then surface the 500. Dedupe still blocks true duplicates (they
succeed and keep the marker) and concurrent double-delivery (ON CONFLICT DO NOTHING).
Cody gate — found a defense-in-depth regression the marker fix INTRODUCED, fixed
in the same commit:
- The release creates a NEW valid state: payments.status='succeeded' while its
booking is still 'pending' (webhook marked the payment, confirmBooking then
failed + released the marker + 500'd for a retry). GET /payments/:id only
reconciled when status==='processing', so a client poll landing before the
provider retry would return 'succeeded' while the booking sat pending, invisible
to the only in-repo rescue path (there is no cron). Fix: GET /payments/:id now
calls confirmBooking(booking_id) whenever the payment is 'succeeded' (idempotent),
not just inside the 'processing' branch — so the poll rescues that state from any
interleaving.
- The release DELETE's `.catch(()=>{})` swallowed a failed release silently
(regressing to the original bug with no trace). Now logs distinctly which event
stayed broken.
Cody cleared (verified, no defect): double-processing on retry (confirmBooking is
guarded by status='pending' -> no 2nd WhatsApp; refund UPDATE is a flat idempotent
SET); evId/ref key drift (release deletes by the same evId firstTime inserted); and
the 0-rows-matched case (correctly NOT released — payments.status stays 'processing'
so the existing poll covers it; releasing would only hammer retry budgets on
genuinely-unmatchable test/ping events).
Tests (+5, suite 170 -> 175):
- webhooks-route.test.js (+2): a processing failure -> 500 AND a DELETE
webhook_events (marker released); a successful delivery -> NO release.
- payment-poll-rescue.test.js (NEW, 3): a succeeded+pending booking is rescued by
the poll's confirmBooking; an already-confirmed booking is a no-op (no
double-notify); a still-processing payment polls as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
47088ae · 2026-09-23 · cycle 11 docs: YOLO_NOTES ledger — systemic async-error hardening, real-router proof, 11-cycle summary
Records the harden()/global-error-handler fix closing the request-hang gap,
Cody's required real-router test, the free double-next() documentation, and
the honest webhooks-is-a-no-op note. Summarizes 11 cycles of provider-agnostic
hardening now complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
16c0a2b · 2026-09-23 · costa-rica: wrap all sub-router async routes in an error-forwarding harden(), close the request-hang gap (Cody gate, cycle 11) — TK-10346
GO-LIVE PRE-FLIGHT (systemic, deferred from cycle 10): Express 4 does NOT forward
a rejected promise from an async route handler to error-handling middleware. An
uncaught throw inside `async (req,res)=>{...}` was an unhandledRejection — cycle 10
added a process-level net so that no longer CRASHES the server, but the offending
request still HUNG FOREVER (no response ever sent). This closes the hang.
New lib/async-harden.js: `harden(router)` wraps every ordinary (non-error, arity<4)
route handler in a router's stack so a thrown/rejected handler calls next(err) ->
the app's global error handler returns a clean 500. A handler with its own
try/catch never rejects, so it's an unaffected pass-through (existing try/catch
still wins, no double-response). Idempotent (won't double-wrap).
Wired into server.js: the 5 sub-router mounts (webhooks, /api/app, /api/admin,
/api/build, /api/logo-agent) now go through harden(); a global 4-arg error
handler is mounted LAST (after express.static, before the boot guard), reusing
the existing serverError() pattern (log full detail server-side, generic client
message), guarded on res.headersSent. The ~20 INLINE server.js routes already had
their own try/catch + serverError and are unaffected (this targets the sub-routers,
where the gap actually was).
Cody gate — clears the bar, one required follow-up + one free fix, both done:
- REQUIRED: the original 8 tests proved the MECHANISM against a throwaway
router built inline in the test, never against the real routes/app.js router
— so a regression (server.js losing its harden(...) call) would go uncaught
by CI. Added test/async-harden-real-router.test.js: mounts the ACTUAL router
from routes/app.js through the REAL harden(), drives the genuinely-uncaught
`GET /listings/:slug` (routes/app.js:137, no try/catch) through a rejecting
pool.query, proves the full real chain resolves to a clean 500 in <5s (not a
hang) — plus confirms a normal call through the same hardened route is
unaffected.
- FREE: documented (not fixed — inert today, checked every handler across all
5 routers) the latent next()-then-later-throw double-next limitation shared
with upstream express-async-handler.
- NOTED, not a defect: harden(webhooks router) is a no-op (its handlers already
self-catch) — the fix's value is entirely in app/admin/build/logo-agent.
- Also verified (bonus, pre-existing): server.js:597's `/sitemap.xml` catch
block explicitly calls next(err) — it now lands on this new handler instead
of Express's default (which would have leaked a stack trace in dev).
Tests (+10, suite 160 -> 170): async-harden.test.js (8: asyncWrap sync/async
forwarding, idempotency, E2E throw->500/normal->200/self-caught->own-status) +
async-harden-real-router.test.js (2, the required real-router proof).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
5c5fc34 · 2026-09-23 · cycle 10 docs: YOLO_NOTES ledger — plaid.js fetch bound + uncaught-throw/false-verified fixes + global unhandledRejection net
Records the Plaid audit, the Cody-found holes inside the same function (bare
pool.query throws, getAuth-swallow persisting a false-verified bank), the global
crash net, and schedules the systemic per-route asyncHandler refactor for next cycle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
8c3bc3c · 2026-09-23 · costa-rica: bound Plaid fetch + fix uncaught-throw/false-verified holes in the host bank-linking path (Cody gate, cycle 10) — TK-10346
Cycle-10 audit of lib/plaid.js (Plaid ACH bank-linking for foreign hosts).
FINDING A — unbounded fetch (fixed): lib/plaid.js _post used raw fetch() with no
timeout, the last unbounded live provider call. Routed through the shared fetchT
(bounds connect+headers AND the body read, like tilopay/onvo). Closes the
PRE-FLIGHT #4/#5 class for Plaid.
FINDING B — uncaught async throws crash the process (fixed for this path): the
Plaid routes had no try/catch and this router has NO error middleware (Express 4),
so on Node 26 a live Plaid throw is an unhandledRejection that CRASHES the whole
marketplace. Wrapped /host/plaid/link-token + /host/plaid/exchange -> clean
502 (504 on PROVIDER_TIMEOUT).
Cody's gate found two more holes INSIDE the same function's blast radius + one
free global net — all fixed this cycle rather than split:
1. /host/plaid/exchange's two pool.query calls (INSERT payout_method, UPDATE
hosts) were still bare awaits below the fix — a DB throw there crashed the
process the same way. Wrapped -> 502.
2. getAuth().catch(()=>({accounts:[]})) swallowed a verification failure, then
persisted the payout method verified=TRUE with a NULL account_id/last4 — a
silently-verified bank with no payout destination. Now `verified` reflects
reality (!!acct.account_id); an unverified method is is_default=FALSE and is
NEVER set as the host default (so a future ACH payout can't target a bank
with no account).
3. Client error messages echoed Plaid's error_code + the plaid.com URL to the
caller (CWE-209). Now log full detail server-side, return a generic message.
4. Added a global process.on('unhandledRejection') net in server.js: log + stay
up, so an uncaught throw in ANY of the ~21 still-unwrapped async routes fails
that one request instead of crashing the whole server. The proper per-route
asyncHandler + error-middleware refactor is a separate scheduled cycle.
Tests (+9, suite 151 -> 160):
- plaid-lib-timeout.test.js (3): stalled body -> PROVIDER_TIMEOUT; 4xx fails
closed; sandbox no-fetch.
- plaid-routes.test.js (6): link-token error->502 (+no leak), timeout->504,
exchange error->502 (no method persisted), getAuth-fail->verified=FALSE + not
default, getAuth-ok->verified=TRUE + default, DB-throw-on-INSERT->502.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
0e2af37 · 2026-09-23 · cycle 9 docs: YOLO_NOTES ledger — admin.js audit, TOCTOU race fix, gated approval decision
Records the routes/admin.js coverage add, the Cody-verified unenforced-approval
finding (with corrected severity: attributed-recipient not paid, since
createPayoutForBooking has zero callers), the directly-fixed TOCTOU
silent-overwrite race, and the gated REQUIRE_HOST_APPROVAL decision memo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
002feea · 2026-09-23 · costa-rica: fix TOCTOU silent-overwrite race in /host/listings ownership (Cody gate, cycle 9) — TK-10346
Cody's audit of the claim-approval finding surfaced a SHARPER, separate bug in the
same guard block: the guard SELECT and the place_booking upsert are separate
statements with no lock between them. Two hosts racing to list the same
never-before-listed place could BOTH read current_host=NULL, both pass the guard,
and both run the upsert — and the old unconditional
`ON CONFLICT (place_id) DO UPDATE SET host_id=EXCLUDED.host_id` let the second
writer SILENTLY overwrite the first's ownership and STILL return 200. Last commit
wins; the loser is told nothing (a false success), and the wrong host becomes
place_booking.host_id — the attributed payout recipient for every booking on that
place.
This is a pure correctness bug with no legitimate "intended design" reading, so
it's fixed directly (not gated): added
`WHERE place_booking.host_id = EXCLUDED.host_id OR place_booking.host_id IS NULL`
to the ON CONFLICT DO UPDATE. The single statement is now atomic — the update
applies only when the caller already owns the row (re-list) or it is unowned
(host deleted -> NULL); a racing non-owner matches no row -> empty RETURNING ->
the route now returns 409 instead of a false 200. The host-deleted re-list path
(NULL host_id) is preserved.
NOTE: this is the pure-correctness half. Whether admin approval (claim_status=
'approved') should ALSO gate listing is a separate customer-facing onboarding
decision — drafted to pending-approval, NOT changed here.
Tests (+3, suite 148 -> 151):
- host-listing-race.test.js (NEW, 2, real DB): a non-owner cannot silently
overwrite the owner (empty RETURNING); ownership stays with the first host;
the owner can re-list; an unowned (NULL) place can still be claimed.
- host-listing-approval-gap.test.js: the route turns an empty upsert RETURNING
into a 409 + the upsert carries the atomic ownership WHERE clause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
e57398f · 2026-09-23 · costa-rica: add routes/admin.js coverage (was zero) + characterize the unenforced claim-approval gap — TK-10346
Cycle 9 audit of routes/admin.js (previously zero test coverage).
admin-routes.test.js (+7): baseline coverage for all 4 admin endpoints —
GET /stats, GET /claims (default pending + status=all), POST /claims/:placeId/:hostId
(bad-decision 400 before any write, approve 200 + parameterized args, 404 on no row),
GET /bookings.
host-listing-approval-gap.test.js (+2): CHARACTERIZATION of a real authorization
finding surfaced by the audit — the admin approve/reject workflow writes
place_hosts.claim_status, but the point that actually grants a host the money
(routes/app.js POST /host/listings -> place_booking.host_id -> every booking's
payout recipient) gates on claim ROW EXISTENCE, not claim_status='approved'. So a
'pending' (or 'rejected') claim can still list. Exclusivity IS enforced (first-come
via the place_booking 409 guard); approval is not. The test asserts the structural
fact (the guard SQL has no claim_status filter) so any deliberate future enforcement
flips it visibly. Whether approval SHOULD gate listing is a customer-facing
onboarding decision -> drafted to pending-approval, not changed here.
No source change — coverage + characterization only. Suite 139 -> 148.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
2a75e80 · 2026-09-23 · cycle 8 docs: YOLO_NOTES ledger — fetchT clone() hardening, Cody-required proof test
Records the res.clone() Proxy hardening + the Cody-required sequential
clone-after-read proof test (not just an asserted comment). Notes the
provider-agnostic money-path backlog is now fully exhausted across 5 cycles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
03b5617 · 2026-09-23 · costa-rica: harden fetchT's clone() + full body-method class, proven not asserted (TK-10346)
Closes the res.clone() gap Cody flagged as latent in cycle 6: a naked res.clone()
returned the RAW un-proxied Response, so a future retry-with-clone caller would get
an unbounded body read with zero indication anything's wrong. No current caller
uses clone() (reconfirmed via grep) — this is hardening-before-it-bites, not a live
bug fix.
Refactored fetchT's Proxy into a reusable wrap(r) applied to both the original
Response AND any res.clone() (clone tees the same incoming stream + shares the
abort signal, so an abort still aborts both readers). Also generalized coverage
from json()/text() only to the full body-consuming method class (arrayBuffer,
blob, formData, bytes) — a caller switching methods would otherwise silently
lose the bound.
Cody gate — SHIP IT, with one required addition: the code comment asserted
"once either read completes, a second read draws from already-buffered bytes"
(the safety argument for sharing one timer/done() across original+clone) but
NO TEST proved that specific compound claim — only individual body-method
bounding was tested. Cody built throwaway probes confirming it empirically
against real WHATWG stream tee() semantics, then required it ported into the
real suite as a red-goes-green guardrail rather than left as a trusted
paragraph. Added: a real delayed-ReadableStream Response, clone() BEFORE any
read, fully drain the ORIGINAL (clearing the shared timer), then assert the
CLONE resolves near-instantly off tee-buffered bytes rather than re-stalling
on the network with no timer left to bound it.
Cody also verified (clean, no defect): clone-of-clone doesn't double-wrap;
clone-after-consumed throws synchronously (a caller bug, correctly NOT
relabelled as PROVIDER_TIMEOUT); Reflect.get(target,prop,target) still passes
the correct receiver for brand-checked getters/Symbols after generalizing the
body-method set; a mock missing a body method degrades to plain delegation
(no crash).
Tests (+4, suite 135 -> 139): clone reads through the wrapper + original still
independently readable; a stalled clone body rejects PROVIDER_TIMEOUT (not
raw/unbounded); a stalled arrayBuffer() is bounded (the full method class, not
just json/text); the sequential clone-after-original-read proof (Cody-required).
Provider-agnostic hardening backlog (#4/#5/#6/#7/§5b#2 + this clone hygiene item)
is now fully closed. Remaining money-path items are live-credential-gated
(CR-KYC blocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic