From 8c36f40efc09556f51d26b83d1694194d752c459 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 18 Jul 2026 04:01:19 -0400 Subject: [PATCH] fix(payment): kill double invoice + restore Smirk one-click (status 403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Double invoice: the "Buy Selected Package" button carries BOTH id=buy-credits-btn AND class=mode-option, so a click fired the generic .mode-option handler (no data attrs -> defaulted to single_game -> a spurious 1-credit invoice) AND its own buy-credits handler (the real pack). Guard the generic handler to ignore #buy-credits-btn and intentless clicks. 2) Smirk one-click payment never opened: /api/auth/smirk/status 403d, leaving _isLinked=false so onPaymentCreated skipped the Smirk path. The BOLA-hardened query matched socket_id AND anon_token, but socket_id is VOLATILE (changes every refresh) so it returned 0 rows after a reload. Match on the anon_token alone — the actual ownership secret — same protection, no refresh fragility. --- html/js/ui/paymentUI.js | 5 +++++ src/routes/auth.js | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/html/js/ui/paymentUI.js b/html/js/ui/paymentUI.js index 22477f7..56eec6e 100644 --- a/html/js/ui/paymentUI.js +++ b/html/js/ui/paymentUI.js @@ -18,9 +18,14 @@ const PaymentUI = { // Bind mode options (use delegation for dynamic content) $('#payment-ui').on('click', '.mode-option', function() { + // The "Buy Selected Package" button ALSO carries .mode-option (for styling) but has its + // own handler below; without this guard it double-fired here with NO data attrs, defaulting + // to single_game — so buying a 10-credit pack ALSO created a spurious 1-credit invoice. + if (this.id === 'buy-credits-btn') return; const mode = $(this).data('mode'); const action = $(this).data('action'); const packageId = $(this).data('package-id'); + if (!mode && !action) return; // stray click with no intent PaymentUI.handleModeSelection(mode, action, packageId); }); diff --git a/src/routes/auth.js b/src/routes/auth.js index 278823f..e097c11 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -251,14 +251,20 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { return res.status(401).json({ error: 'Session token required' }); } + // Identify the caller by anon_token ALONE — it is the ownership secret. The previous query also + // required socket_id = current socket.id, but socket_id is VOLATILE (changes on every refresh / + // reconnect), so after a refresh the row's stored socket_id no longer matched and this 403'd — + // which left the client's _isLinked=false and silently disabled Smirk one-click payment. Matching + // on the unguessable token is the same guarantee (you can only read your OWN state) without the + // socket_id fragility. (socketId is still required in the query string for API shape.) const result = await db.query(` SELECT smirk_public_key, payout_address FROM users - WHERE socket_id = $1 AND anon_token = $2 - `, [socketId, token]); + WHERE anon_token = $1 + `, [token]); if (result.rows.length === 0) { - // Do not disclose whether the socketId exists — ownership check failed. + // Unknown/again-rotated token — ownership check failed; disclose nothing. return res.status(403).json({ error: 'Session ownership verification failed' }); }