| fixed | high | src/lib/buyer.ts buyersByEmail, reached from src/app/api/recover/route.ts. Fixed in outrip 2026-09-19: .eq on the lowercased address. Fixed in outrip 2026-09-19 and re-measured: three wildcard requests leave the fixture at 3 buyers and 1200 dust. | buyersByEmail looked the address up with .ilike, and PostgREST treats _, % and * in an ilike value as wildcards. /api/recover's own address check accepts all three, so the public unauthenticated address %@%.% matched every buyer holding an address and mergeBuyers folded the entire buyer base into one row. Measured: one request took the fixture from 1200 to 2400 dust and each further request added another 1200, unbounded, and dust mints ranked board cards through the shop. |
| fixed | high | src/lib/buyer.ts mergeBuyers (the unchecked .delete()), src/app/api/recover/route.ts. Fixed in outrip 2026-09-19 and re-measured: three wildcard requests leave the fixture at 3 buyers and 1200 dust. | POST /api/recover re-credits a duplicate buyer's dust on every call. mergeBuyers adds the duplicate's dust to the kept row and then deletes the duplicate without checking the error; outrip_products.owner_buyer_id is a NO ACTION foreign key, so if the duplicate owns any board row the delete fails silently and the next call adds the same dust again. Unauthenticated, one HTTP request each, and dust mints real ranked cards through the shop. Measured against the running app 2026-09-19: 1200 dust, then 1700, then 2200. |
| fixed | high | src/lib/buyer.ts mergeBuyers. Fixed in outrip 2026-09-19 and re-measured: three wildcard requests leave the fixture at 3 buyers and 1200 dust. | POST /api/recover permanently destroys the duplicate rows' Tool Vault cards. mergeBuyers moves outrip_pulls, outrip_orders and outrip_dust_ledger onto the kept buyer, but outrip_tool_pulls is not in that list and its buyer_id foreign key is ON DELETE CASCADE, so those rows go with the deleted buyer. Anyone who paid twice from two browsers loses the Tool Vault cards from the second one the first time they sign back in. Measured 2026-09-19: one tool pull before the call, zero after. |
| fixed | medium | src/lib/orders.ts refundOrder, src/lib/collection.ts buyFromShop | A chargeback keeps the dust and buys a board seat with it. refundOrder marks the pulls traded_in and closes the order; it does not reverse dust already paid out for those same cards, and buyFromShop's seat check reads outrip_pulls with no traded_in filter, so a buyer holding zero live cards on a domain still buys onto it. Measured end to end 2026-09-19: five cards traded for 1227 dust, the charge reversed, the dust kept, and a rating-723 card minted onto the board with nothing live on the seat. |
| fixed | medium | src/app/rip/page.tsx, src/app/rip/tools/page.tsx, src/app/api/claim/route.ts, src/app/api/rip/open/route.ts | settleFromStripe is called unguarded from four of its five callers, so a Stripe outage or an expired session answers 500 instead of the product's own unpaid path. /rip, /rip/tools, /api/claim and /api/rip/open all call it bare; unopenedOrder in orders.ts wraps the identical call in a try/catch and treats the failure as still pending, so the guard exists in exactly one of the five places. Measured 2026-09-19: loading /rip with a pending order whose session lookup fails returned 500 rather than redirecting to /packs?unpaid=1, so a buyer who paid sees an error page when the processor is slow. |
| fixed | low | src/app/out/[domain]/route.ts clientHash. Fixed 2026-09-19 in outrip 06756f9: dedup uses only Cloudflare's or the origin proxy's edge-written client address and ignores caller-controlled X-Forwarded-For. | The Reach dedup key is taken from a client-settable header with no trusted-proxy check. /out/[domain] hashes x-forwarded-for and reads split(',')[0], the leftmost value, which is the one a caller supplies when a proxy appends rather than replaces. A caller that varies the header chooses its own dedup key. Source-verified only: which value the live edge puts first was not measured, because measuring it means writing real click rows on the production board. |
| fixed | low | src/lib/collection.ts and supabase/migrations/20260919225000_atomic_dust_shop.sql. Fixed 2026-09-19 in outrip 2f3a618 and applied to production: trade-in and shop purchase run as database transactions with row locks around buyer balances, product pull indexes and shop stock. | Stock and dust are both read-modify-write, so concurrent calls oversell and lose updates. buyFromShop reads item.stock and writes stock minus one with a .gt('stock', 0) filter that is true for both of two concurrent callers, so one unit of stock mints two cards; tradeIn and buyFromShop both read the dust balance and write a computed total rather than an increment. |