← back to Costa Rica
costa-rica: bound booking money amounts against int4 overflow + availability-squat + min_nights lockout (Cody gate, cycle 14) — TK-10346
faf2ab6c7917c20917fde2ae27684dde2c223637 · 2026-09-23 22:43:44 -0700 · Steve
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
Files touched
M routes/app.jsM test/booking.test.js
Diff
commit faf2ab6c7917c20917fde2ae27684dde2c223637
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 23 22:43:44 2026 -0700
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
---
routes/app.js | 23 ++++++++++++++++++++++
test/booking.test.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+)
diff --git a/routes/app.js b/routes/app.js
index 44832ef..8705be9 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -182,6 +182,12 @@ router.post('/bookings', authRequired, async (req, res) => {
return bad(res, 400, 'check_out must be after check_in');
const n = nights(check_in, check_out);
if (n < (pb.min_nights || 1)) return bad(res, 400, `min ${pb.min_nights} nights`);
+ // Cap the stay length: an unbounded date range makes subtotal = base_price * n
+ // overflow the INTEGER money columns (a 500), and even below overflow lets a
+ // client create an absurd multi-decade booking that squats the listing's
+ // availability (the overlap guard then blocks every real booking for years).
+ const MAX_NIGHTS = Number.parseInt(process.env.MAX_BOOKING_NIGHTS || '365', 10);
+ if (n > MAX_NIGHTS) return bad(res, 400, `maximum stay is ${MAX_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(
@@ -198,6 +204,16 @@ router.post('/bookings', authRequired, async (req, res) => {
subtotal = pb.base_price * g; // slot/ticket priced per guest (validated integer)
}
const split = computeSplit({ subtotal, cleaningFee: pb.cleaning_fee, currency: pb.currency, platformFeeBps: pb.platform_fee_bps });
+ // Overflow guard: EVERY stored bookings money column is INTEGER (int4, max
+ // 2,147,483,647). Guard each one, not just `total` — `fees = cleaningFee +
+ // platformFee` can reach ~2x total and overflows int4 while total is still under
+ // it (e.g. a host with a huge cleaning_fee, at the default 10% fee), so a
+ // total-only check would still let the INSERT 500 on "integer out of range".
+ // `max_guests`/`cleaning_fee` are host-set and uncapped by any DB CHECK, so the
+ // inputs can be large enough to matter. (Cody gate, cycle 14.)
+ const INT4_MAX = 2_147_483_647;
+ const amounts = [split.subtotal, split.fees, split.platformFee, split.total, split.hostPayout];
+ if (!amounts.every(v => Number.isSafeInteger(v) && v >= 0 && v <= INT4_MAX)) return bad(res, 400, 'booking total exceeds the maximum');
const code = bookingCode();
const { rows: [bk] } = await pool.query(
`INSERT INTO bookings (code, place_id, host_id, traveler_id, check_in, check_out, slot_start, slot_end,
@@ -465,6 +481,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');
+ // Reject a min_nights above the booking cap: it would make the listing
+ // permanently unbookable (a booking needs n >= min_nights AND n <= MAX_BOOKING_NIGHTS,
+ // impossible when min_nights > MAX). Guard at write time so the bad config can't
+ // be created. (Cody gate, cycle 14.)
+ const MAX_NIGHTS = Number.parseInt(process.env.MAX_BOOKING_NIGHTS || '365', 10);
+ if (b.min_nights != null && (Number.parseInt(b.min_nights, 10) || 0) > MAX_NIGHTS)
+ return bad(res, 400, `min_nights cannot exceed the ${MAX_NIGHTS}-night booking cap`);
// 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(
diff --git a/test/booking.test.js b/test/booking.test.js
index c5bcc00..a1b09b7 100644
--- a/test/booking.test.js
+++ b/test/booking.test.js
@@ -133,6 +133,61 @@ test('P2: POST /bookings with a non-ISO check_in → 400 and NO INSERT (no NaN m
assert.equal(hasSql(/INSERT INTO bookings/), false, 'an unparseable date must never reach the INSERT');
});
+// Money-math hardening (cycle 14): an unbounded date range overflows the INTEGER
+// money columns (a 500) and lets a client squat a listing's availability for years.
+test('MONEY: a stay longer than MAX_BOOKING_NIGHTS → 400 and NO INSERT (no overflow, no availability-squat)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ reset([PB()]); // only the place_booking SELECT — the cap fires before the overlap query + INSERT
+ const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-01-01', check_out: '2028-01-01', guests: 2 }, token); // ~731 nights
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /maximum stay/i);
+ assert.equal(hasSql(/INSERT INTO bookings/), false, 'an over-long stay must never be inserted');
+ assert.equal(hasSql(/SELECT 1 FROM bookings WHERE place_id/), false, 'the cap fires before the overlap query');
+});
+
+test('MONEY: a booking SUBTOTAL that would overflow int4 → 400 and NO INSERT (clean reject, not a DB 500)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ // base_price $60k/night; ~364 nights (<= MAX 365) -> subtotal ~2.18e9 > int4 max.
+ const bigPB = () => ({ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 6000000, cleaning_fee: 0, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] });
+ reset([bigPB(), { rows: [] } /* no overlap */]);
+ const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-01-01', check_out: '2026-12-31', guests: 2 }, token); // 364 nights
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /exceeds the maximum/i);
+ assert.equal(hasSql(/INSERT INTO bookings/), false, 'an overflowing subtotal must never reach the INSERT (would be a Postgres int4 500)');
+});
+
+// NEGATIVE TEST (Cody gate, cycle 14 — TK-11431 doctrine): the overflow guard must
+// go RED on the column that actually overflows FIRST. `fees = cleaningFee +
+// platformFee` reaches ~2x total, so a huge host-set cleaning_fee overflows the
+// `fees` int4 column at the DEFAULT 10% fee while `total` is still under int4 max —
+// a total-only guard would let this INSERT 500. This proves the guard catches `fees`.
+test('MONEY: a huge cleaning_fee overflows the FEES column (total still under int4) → 400 and NO INSERT', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ // cleaning_fee ~2e9, tiny subtotal -> total ~2e9 (under int4 max), but
+ // fees = cleaning_fee + platformFee(10%) ~2.2e9 > int4 max.
+ const feePB = () => ({ rows: [{ max_guests: 4, booking_type: 'nightly', min_nights: 1, base_price: 100, cleaning_fee: 1999999000, platform_fee_bps: 1000, place_id: 5, host_id: 9, currency: 'USD', name: 'Casa' }] });
+ reset([feePB(), { rows: [] } /* no overlap */]);
+ const r = await post('/api/app/bookings', { place_slug: 'casa', check_in: '2026-09-10', check_out: '2026-09-13', guests: 2 }, token); // 3 nights
+ assert.equal(r.status, 400, 'fees overflow is rejected even though total is under int4 max');
+ assert.match(r.json.error, /exceeds the maximum/i);
+ assert.equal(hasSql(/INSERT INTO bookings/), false, 'a fees-overflowing booking must never reach the INSERT');
+});
+
+// Config-trap guard (Cody gate, cycle 14): a min_nights above the MAX_BOOKING_NIGHTS
+// cap makes a listing permanently unbookable (n>=min_nights AND n<=MAX is impossible).
+// Reject it at listing-write time.
+test('HOST: /host/listings rejects min_nights above the booking cap (400, no self-lockout listing created)', async () => {
+ const token = signToken({ sub: 3, role: 'guest' });
+ reset([
+ { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }, // requireHost
+ { rows: [{ id: 5 }] }, // SELECT place by slug
+ ]);
+ const r = await post('/api/app/host/listings', { place_slug: 'casa', base_price: 12000, min_nights: 400 }, token);
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /min_nights/i);
+ assert.equal(hasSql(/INSERT INTO place_booking/), false, 'an unbookable (min_nights>cap) listing must never be created');
+});
+
// P5 — slot/tour pricing (base_price*guests) + the guests>max_guests gate (C1).
test('P5: slot/tour booking prices base_price*guests and inserts (happy path)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
← a5c06b3 cycle 13 docs: YOLO_NOTES ledger — whatsapp + apple fetch bo
·
back to Costa Rica
·
cycle 14 docs: YOLO_NOTES ledger + GO-LIVE processorFee prer 9afa84b →