← back to Costa Rica
docs/GO-LIVE.md
91 lines
# Costa Rica Marketplace — GO-LIVE runbook
Repo: `~/Projects/costa-rica` (backend) · prod: Kamatera `root@45.61.58.125`, pm2 `costa-rica` :9791, `costarica.agentabrams.com`.
Status as of 2026-08-08: **backend LIVE on prod in SANDBOX mode**; DB migrations applied; hardened code deployed; iOS build #13 on TestFlight. Suite: `DATABASE_URL=postgresql:///costa_rica_directory?host=/tmp npm test` → 113/113.
Everything runs sandbox-safe with NO creds. Each integration flips live the moment its keys are set. The boot guard (`lib/preflight.js`) **refuses to start in production** if any integration is live but missing its webhook secret / verify token — so a half-configured go-live fails loud instead of silently dropping bookings.
---
## 0. What only YOU (Steve) can do — real-world credential steps
These are NOT permission gates; they need your own logins/identity and cannot be automated:
1. **Tilopay merchant account** — requires a CR bank IBAN + Ministerio de Hacienda tax registration + notarial shareholding cert (or a non-CR processor). No API keys exist until this is done.
2. **Meta WhatsApp (WABA)** — Meta Business + WhatsApp number + permanent system-user token + pre-approved templates.
3. **Plaid production** — create prod keys in the Plaid dashboard.
4. **App Store Submit-for-Review** — your Apple ID login in the ASC console (build/desc/screenshots already staged).
## 1. Set secrets (when you have them)
Paste each key; route via the `secrets` skill → `~/Projects/costa-rica/.env` on prod. Never commit `.env` (deploys exclude it).
```
# payments (pick one provider via PAYMENT_PROVIDER=tilopay|onvo)
TILOPAY_API_USER= TILOPAY_API_PASSWORD= TILOPAY_API_KEY= TILOPAY_WEBHOOK_SECRET=
# whatsapp
WHATSAPP_TOKEN= WHATSAPP_PHONE_ID= WHATSAPP_APP_SECRET= WHATSAPP_VERIFY_TOKEN=<random, NOT the default>
# plaid
PLAID_CLIENT_ID= PLAID_SECRET= PLAID_ENV=production
```
The boot guard will REFUSE to start if a provider is live but its `*_WEBHOOK_SECRET` (or `WHATSAPP_VERIFY_TOKEN`) is unset — set them together.
## 2. Register webhooks with the providers
- Meta WhatsApp: `https://costarica.agentabrams.com/webhooks/whatsapp` (GET verify uses `WHATSAPP_VERIFY_TOKEN`).
- Tilopay/ONVO: point the processor's webhook at `https://costarica.agentabrams.com/webhooks/tilopay` (or `/onvo`).
## 3. DB migrations (already applied on prod 2026-08-08)
Ordered, idempotent, ledgered runner:
```
cd /root/Projects/costa-rica
export DATABASE_URL="$(grep -E '^DATABASE_URL=' .env | cut -d= -f2-)"
bash scripts/apply-migrations.sh --status # READ-ONLY: applied vs pending
bash scripts/apply-migrations.sh # apply pending (idempotent; existing objects no-op)
psql "$DATABASE_URL" -c "SELECT conname FROM pg_constraint WHERE conname IN ('bookings_no_overlap_stay','bookings_no_overlap_slot');" # 0 rows => STOP
```
NEVER `--baseline` on prod (marks pending files applied WITHOUT running → would skip the double-book/integrity guards); the runner refuses it unless `FORCE=1`. For partial adoption use `--baseline-through <file>`.
**`migrate_011_payments_one_inflight.sql` prerequisite (double-charge backstop):** builds a partial UNIQUE index on the HOT `payments` table. BEFORE applying, run the pre-flight duplicate check (in the migration header) — if any booking already has ≥2 in-flight payments the build fails; remediate (keep newest in-flight, mark the rest `failed`, verify against the processor which actually charged). The file uses `CREATE UNIQUE INDEX CONCURRENTLY` so it won't block checkout writes; an interrupted build leaves an INVALID index → `DROP INDEX CONCURRENTLY IF EXISTS payments_one_inflight_per_booking;` and re-run.
**`migrate_013_wa_auto_reply_cooldown.sql` prerequisite (WhatsApp cost guard):** adds a nullable `whatsapp_contacts.last_auto_reply_at TIMESTAMPTZ` backing a per-contact auto-reply cooldown (caps Meta-billed sends from an inbound-keyword flood). Additive, `IF NOT EXISTS`, no backfill (NULL = eligible), safe online (brief lock on a small table). No pre-flight needed. The migration header also documents two hostile-data columns to keep in mind: `whatsapp_contacts.profile_name` + `whatsapp_messages.body` are stored RAW from attacker-controlled inbound payloads (escape at any future admin render — see cycle-24 XSS), and the unused `whatsapp_contacts.user_id` FK must not be wired to a bare wa_id/phone match without an OTP/consent step (impersonation).
**`migrate_012_payout_method_completeness.sql` prerequisite (payout completeness backstop):** adds two CHECK constraints so a `payout_methods` row carries the identifier its `kind` pays to (`sinpe_movil`→`sinpe_phone`, `cr_iban`→`cr_iban`); without them an incomplete method silently pays a host $0 in LIVE. A plain `ADD CONSTRAINT` fails if any existing row violates, so BEFORE applying run the pre-flight in the migration header — `SELECT id, kind FROM payout_methods WHERE (kind='sinpe_movil' AND sinpe_phone IS NULL) OR (kind='cr_iban' AND cr_iban IS NULL);` — and fix/delete any hits (verify the host's real payout target) first. `payout_methods` is a low-write table (host-initiated registration only, not hot like `payments`), so the brief validation lock is fine; no `CONCURRENTLY`/`NOT VALID` two-step needed at this scale. `plaid_ach` is intentionally unconstrained (its identifier arrives via the `/host/plaid` exchange). NOTE: `cr_iban` is registerable but the IBAN payout rail is NOT wired — a `cr_iban` payout fails loud in LIVE (`lib/payouts.js` guard) until wired; see the pending host-payout-visibility decision memo.
**`migrate_010_search_trgm.sql` prerequisite:** it runs `CREATE EXTENSION pg_trgm`, which needs **SUPERUSER / rds_superuser**. If the app's DB role isn't a superuser on prod, run the extension line once as the superuser BEFORE the migration pass: `psql "$SUPERUSER_URL" -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'`. The three `CREATE INDEX CONCURRENTLY` lines then build without blocking writes to `places`; if a build aborts it leaves an INVALID index → `DROP INDEX CONCURRENTLY IF EXISTS idx_places_<col>_trgm;` and re-run.
## 4. Deploy code to prod — SURGICAL only (do NOT use canonical /deploy)
`deploy.sh` uses `rsync --delete` and prod holds prod-only data (`data/img/places/*.jpg` live images, OSM cache) missing locally → `/deploy` would erase them. Until local↔prod converge, ship code with a no-delete rsync (lib/routes WITHOUT trailing slashes so they stay directories):
```
cd ~/Projects/costa-rica
rsync -az server.js package.json package-lock.json lib routes root@45.61.58.125:/root/Projects/costa-rica/
ssh root@45.61.58.125 'pm2 reload costa-rica --update-env'
curl -s https://costarica.agentabrams.com/api/app/health # expect ok:true
```
## 5. Money-path hardening — LANDED (provider-agnostic, sandbox-inert, tested)
These closed in the /yoloforever cycles; they need no creds and are already live in code:
- **#4/#5 fetch timeout** — every live provider call is bounded by `fetchT` (`lib/payments/http.js`, env `PROVIDER_HTTP_TIMEOUT_MS`, default 15s) so a hung TCP can't strand a payment/payout in `processing` forever.
- **#7 body-read timeout** — the deadline spans the **whole** request (connect + headers AND the `res.json()` body read), so a header-fast/body-stalled provider can't hang the money path. `refund()`/`payout()` **fail closed** on a body-read timeout (payout throws → `payouts.js` marks the row `failed`; refund → `failed`) instead of fabricating a success.
- **#6 charge idempotency (local half)** — `POST /bookings/:code/pay` writes a `processing` payments row **before** `createCharge()` and reuses an in-flight payment on retry (no double-charge); a timeout leaves a reconcilable row.
- **§5b #2 createCharge error status (both halves)** — a live 4xx/5xx **and** a synchronous 200-body decline (Stripe-like intent creation; tilopay's own status vocabulary) both fail closed to `status:'failed'` via each adapter's shared `mapStatus()` (also used by `getCharge`), instead of falling through to `'processing'` with no `providerRef`. The route honors `charge.status==='failed'` — records the row `failed` + returns 402 — rather than flattening every non-succeeded status into `processing`.
## 5b. Live-only preflight (verify against the REAL provider before opening bookings — do NOT guess)
1. **Webhook signature encoding** — adapters compare hex; if the provider signs base64, set `Buffer.from(sig,'base64')` explicitly once the real delivery is observable.
2. **Provider-honored idempotency key (#6 live half)** — pass the provider's real idempotency header/field (ONVO `Idempotency-Key`-style; Tilopay per its live account) on `createCharge` **and** on the SINPE `payout` transfer, so a retry after a timeout can't double-charge a traveler or double-pay a host. The local pre-charge-row half is already in; this is the provider-side guarantee.
3. **Confirm each provider's REAL decline status strings** against `mapStatus()` in `lib/payments/tilopay.js`/`onvo.js` — the maps are built from documented vocabularies (Stripe-like for ONVO), not the live account; a status string the map doesn't recognize safely defaults to `'processing'` (not `'failed'`), so an unmapped real-world decline string would silently NOT fail closed. Watch the `raw` field on any `processing` row that never resolves.
4. **⚠ ONVO refund detection (BLOCKER before `PAYMENT_PROVIDER=onvo`)** — the webhook decides "refunded" from `getCharge().status`. `onvo.js` `STATUS_MAP` now maps `refunded`/`reversed`→`'refunded'` (cycle-29 fix; Tilopay already mapped `reversed`), BUT ONVO's EXACT post-refund vocabulary is UNVERIFIED. If ONVO is Stripe-like, the payment-intent status STAYS `'succeeded'` after a refund (refund is a separate object / a `charge.refunded` flag), in which case the status-map key never fires and `getCharge` must instead inspect the refund object. Verify against ONVO's real API + the $1 refund test below and adjust `getCharge`/`STATUS_MAP` accordingly — do NOT flip ONVO live until a real refund is observed flipping the booking to `refunded`. **The same caveat applies to Tilopay** (`reversed`→`refunded`, tilopay.js:94): neither map is verified against a live account (see item 3), and `reversed` in a real processor can mean an auth VOID of a never-captured charge (money never moved) as easily as a post-capture REFUND (money returned) — both currently collapse to `refunded`. Whichever provider goes live FIRST must pass item 5 before taking real money; treat neither map as pre-verified.
5. **Verify with a $1 real charge + refund** before opening bookings — confirm the refund flips both `payments.status` and `bookings.status` to `refunded` for the ACTIVE provider (and that an auth void, if the flow allows one, is NOT mis-recorded as a refund).
## 6. Verify money math
`total = platform_fee + host_payout` (10% platform fee; `fees` is display-only). Enforced by DB CHECK `bookings_total_reconciles` + `test/money.test.js`. Booking amounts are capped: stays > `MAX_BOOKING_NIGHTS` (default 365) and any amount that would overflow the INTEGER (int4) money columns are rejected with a 400 at `POST /bookings` (cycle-14 guards).
**PREREQUISITE before enabling a processor fee (`processorFeeBps > 0`):** `lib/money.js computeSplit` supports a processor fee and subtracts it from `hostPayout`, but the `bookings` table has NO `processor_fee` column and the CHECK is `total = platform_fee + host_payout` — so `platform_fee + host_payout = total - processorFee ≠ total` would 500 EVERY booking. The `/bookings` route never passes `processorFeeBps` today (inert), but before turning one on you MUST: add a `processor_fee` INTEGER column to `bookings`, update `bookings_total_reconciles` to `total = platform_fee + host_payout + processor_fee`, and have the route pass + store `split.processorFee`. (Cody gate, cycle 14.)
## 7. App Store
ASC app 6799240433; build #13 on TestFlight. Remaining: attach build + `store/listing.md` desc + 5 screenshots + export-compliance in the ASC console, then Submit for Review (your Apple login).
**Sign-in-with-Apple — two pre-launch DECISIONS (cycle-27 Cody audit; crypto core verified sound, these are hardening/UX, not takeover):**
1. **Account-splitting on email mismatch.** `/auth/apple` links by `apple_sub`, else by *verified* email, else INSERTs. A user who registered by password with email X, then signs in with Apple whose currently-verified email is Y (alias / Hide-My-Email toggled / primary changed), falls through to a NEW orphaned `app_users` row — their old bookings become invisible from the new account, silently. Decide before launch: add a "we found another account — merge?" flow, or accept-and-document the risk.
2. **Nonce / replay.** No nonce is generated client-side or checked server-side (`grep nonce` = 0 hits). A captured `identity_token` can be replayed to `/auth/apple` within Apple's short exp window to mint a victim session (requires the attacker to already have the raw bearer credential — medium, not takeover). Decide: add a client nonce ceremony + server nonce-claim check (ideally a one-time-use ledger), or accept-and-document given TLS + short exp. Also note: no rate limiting anywhere in the app (`grep rate.?limit` = 0) — worth adding on `/auth/*` regardless.
## 8. Optional — public web directory
The web directory pages are basic-auth gated (`admin`/`DW2024!`); the app API (`/api/app`) + `/webhooks` are already public. Dropping the web gate exposes ~34k listings publicly — a customer-facing publish of scraped third-party business data; decide deliberately before removing `BASIC_AUTH_*`.
**HARD pre-launch requirement (TK-10346, Cody audit, Steve-approved 2026-09-24):** `/api/admin` (host-claim approvals + traveler PII/bookings), `/admin`, `/api/build` (ops dashboard), and `/api/logo-agent` have NO auth of their own — they ride the SAME `BASIC_AUTH_*` switch as this public directory. Removing `BASIC_AUTH_*` per this section, on its own, also strips admin's only lock. A **separate cookie-session admin auth** (its own `ADMIN_*` secret, `SameSite=Strict`, fully decoupled from `BASIC_AUTH_*`) MUST be built and shipped BEFORE `BASIC_AUTH_*` is removed. Until that ships, a stopgap fail-closed guard (`server.js`) is in place: with `BASIC_AUTH_*` unset, those admin surfaces respond `503 {error:'admin_gate_unconfigured'}` instead of serving openly — so removing `BASIC_AUTH_*` today makes admin go DARK (safe), not OPEN (unsafe). Don't mistake the 503 stopgap for the real fix; it buys time, it doesn't replace the decoupled admin auth.