fix(payment): kill double invoice + restore Smirk one-click (status 403)

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.
This commit is contained in:
jwinterm
2026-07-18 04:01:19 -04:00
parent 534e713010
commit 8c36f40efc
2 changed files with 14 additions and 3 deletions
+5
View File
@@ -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);
});
+9 -3
View File
@@ -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' });
}