diff --git a/lib/core.js b/lib/core.js index 04d1459..912bccf 100644 --- a/lib/core.js +++ b/lib/core.js @@ -1,4 +1,4 @@ -const crypto = require('node:crypto'); +const crypto = require('node:crypto'); class AppError extends Error { constructor(code, message, status = 400) { super(message); this.code = code; this.status = status; } } @@ -16,4 +16,9 @@ class KeyedQueue { async drain() { await Promise.all([...this.tails.values()]); } } const hash = value => crypto.createHash('sha256').update(value).digest('hex'); -module.exports = {AppError, KeyedQueue, hash}; +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])); + return value; +} +module.exports = {AppError, KeyedQueue, hash, canonical}; diff --git a/lib/lobbies.js b/lib/lobbies.js index e889b0b..4e50c52 100644 --- a/lib/lobbies.js +++ b/lib/lobbies.js @@ -1,6 +1,6 @@ const crypto = require('node:crypto'); const {EventEmitter} = require('node:events'); -const {KeyedQueue, AppError, hash} = require('./core'); +const {KeyedQueue, AppError, hash, canonical} = require('./core'); const engine = require('./engine'); function createLobbyService(repository, config) { @@ -38,7 +38,7 @@ function createLobbyService(repository, config) { return queue.run([...(membershipMutation ? ['membership'] : []), ...(username ? [`account:${username}`] : []), ...(message.lobbyId ? [`lobby:${message.lobbyId}`] : [])], async () => { const id = `${username || 'system'}:${message.requestId}`; - const fingerprint = hash(JSON.stringify(message)); + const fingerprint = hash(JSON.stringify(canonical(message))); const prior = await repository.command(id); if (prior) { if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused', 409); diff --git a/lib/repository.js b/lib/repository.js index 4d915e1..52b52f5 100644 --- a/lib/repository.js +++ b/lib/repository.js @@ -47,7 +47,7 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) `); if (old) { // Fails and rolls back rather than silently rounding or losing invalid balances. - await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; DROP TABLE legacy_users;'); + await tx.exec('INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;'); } await tx.exec('PRAGMA user_version = 1'); }); @@ -58,7 +58,10 @@ async function createRepository(filename, {beforeCommit = async () => {}} = {}) user: username => read(tx => tx.get('SELECT username, password_hash AS passwordHash, session_hash AS sessionHash, money FROM users WHERE username = ?', username)), users: () => read(tx => tx.all('SELECT username, money FROM users ORDER BY money DESC, username ASC')), async register(username, passwordHash, sessionHash) { - try { await transaction(tx => tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash)); } + try { await transaction(async tx => { + await tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash); + await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,200,?)', `register:${username}`, username, 'Starting chips'); + }); } catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; } }, setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)), diff --git a/public/client.js b/public/client.js index c1f1d62..6133001 100644 --- a/public/client.js +++ b/public/client.js @@ -1,6 +1,6 @@ 'use strict'; window.apiRequest = async function (url, options = {}) { - const response = await fetch(url, {credentials: 'same-origin', ...options}); + const response = await fetch(url, {credentials: 'same-origin', signal: AbortSignal.timeout(5000), ...options}); const data = await response.json().catch(() => ({})); if (response.status === 401) { localStorage.removeItem('activeLobbyId'); @@ -45,15 +45,16 @@ window.RealtimeClient = class extends EventTarget { } this.dispatchEvent(new CustomEvent('message', {detail: message})); }); - socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…')); + socket.addEventListener('error', () => this.status('Connection interrupted. Reconnecting…')); socket.addEventListener('close', async event => { clearTimeout(timeout); this.ready = false; if (this.stopped) return; - this.status('Connection interrupted. Reconnecting…'); + this.status('Connection interrupted. Reconnecting…'); if (event.code === 1008 && /Session/.test(event.reason)) { this.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login'); return; } try { await apiRequest('/verify', {method: 'POST'}); } catch { /* apiRequest redirects expired sessions; network outages are retried. */ } + if (this.stopped) return; if (++this.attempts > 5) { this.close(); this.status('Could not reconnect. Reload to try again.'); return; } this.reconnectTimer = setTimeout(() => this.open(), Math.min(500 * 2 ** (this.attempts - 1), 5000)); }); diff --git a/public/frontend_poker.js b/public/frontend_poker.js index 2d2b95a..d29c69d 100644 --- a/public/frontend_poker.js +++ b/public/frontend_poker.js @@ -248,6 +248,11 @@ document.addEventListener("DOMContentLoaded", () => { } if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") { + if (["NOT_SEATED", "LOBBY_NOT_FOUND"].includes(message.code)) { + localStorage.removeItem("activeLobbyId"); + location.assign("/"); + return; + } showError(message.message || "Poker connection failed"); } });