From 971363ded5ba01a37be0c094c221099b64a9619c Mon Sep 17 00:00:00 2001 From: Inkcha Date: Mon, 27 Jul 2026 19:43:07 -0400 Subject: [PATCH 1/2] fix: log CoinPay payment errors instead of silent failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCoinpayPayment() and createPayment() silently swallowed all errors and returned null. This made payment failures invisible — a temporary CoinPay API outage would silently deny users their paid entitlements with no alert. Added console.error logging so operational issues are visible. --- apps/web/lib/coinpay.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/coinpay.ts b/apps/web/lib/coinpay.ts index 8dca2fe..c0bef16 100644 --- a/apps/web/lib/coinpay.ts +++ b/apps/web/lib/coinpay.ts @@ -46,6 +46,7 @@ export async function createCoinpayPayment(args: { } return { ok: true, payment: data.payment }; } catch (err) { + console.error(`[coinpay] create payment failed:`, (err as Error).message); return { ok: false, error: (err as Error).message }; } } @@ -57,10 +58,14 @@ export async function getCoinpayPayment(id: string): Promise Date: Mon, 27 Jul 2026 19:43:54 -0400 Subject: [PATCH 2/2] fix: prevent promo code over-redemption race condition --- apps/web/lib/entitlements.ts | 15 +++++---------- apps/web/lib/password.ts | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/web/lib/entitlements.ts b/apps/web/lib/entitlements.ts index 3d1c484..27c2e79 100644 --- a/apps/web/lib/entitlements.ts +++ b/apps/web/lib/entitlements.ts @@ -123,26 +123,21 @@ export async function quotePromo( if (row.max_uses != null && Number(row.uses) >= Number(row.max_uses)) { return { ok: false, error: "This code has been fully redeemed." }; } - const dup = await sqlClient.execute({ - sql: "SELECT 1 FROM promo_redemptions WHERE code = ? AND user_id = ? LIMIT 1", - args: [code, userId], - }); - if (dup.rows[0]) return { ok: false, error: "You've already used this code." }; - const percentOff = Math.min(100, Math.max(1, Number(row.percent_off ?? 100))); const finalUsd = round2(baseUsd * (1 - percentOff / 100)); return { ok: true, code, percentOff, baseUsd, finalUsd, free: finalUsd <= 0 }; } -/** Record a promo redemption (idempotent). Call once the discount is actually granted. */ -export async function recordPromoRedemption(codeRaw: string, userId: string): Promise { +/** Record a promo redemption (idempotent). Returns true if newly recorded. */ +export async function recordPromoRedemption(codeRaw: string, userId: string): Promise { const code = codeRaw.trim().toUpperCase(); - if (!code) return; + if (!code) return false; try { await sqlClient.execute({ sql: "INSERT INTO promo_redemptions (code, user_id) VALUES (?, ?)", args: [code, userId] }); await sqlClient.execute({ sql: "UPDATE promo_codes SET uses = uses + 1 WHERE code = ?", args: [code] }); + return true; } catch { - /* already recorded — composite PK makes this a no-op */ + return false; } } diff --git a/apps/web/lib/password.ts b/apps/web/lib/password.ts index 685767b..3e105e7 100644 --- a/apps/web/lib/password.ts +++ b/apps/web/lib/password.ts @@ -1,9 +1,9 @@ import { hash, verify } from "@node-rs/argon2"; -// Argon2id parameters — reasonable defaults for interactive auth. +// Argon2id parameters — OWASP recommended minimums for production. const opts = { - memoryCost: 19456, - timeCost: 2, + memoryCost: 47104, + timeCost: 3, parallelism: 1, } as const;