Skip to content
This repository was archived by the owner on Apr 30, 2026. It is now read-only.

Commit f1a8d30

Browse files
back2matchingclaude
andcommitted
Phase 12 batch 9: F03 — WS reconnect tokens + queue-broadcast scrubbing
- New persistent reconnectToken issued at /join, stashed on the queue entry, returned in the join response. Paid-reconnect now verifies it via timingSafeEqual; the old "paid players reconnect without any token" branch is gone. - queue_update, pre-identify welcome, and post-identify welcome broadcasts no longer emit raw playerIds of peer entries. The post-identify welcome echoes only the caller's own playerId. - SDK: new reconnectToken field on JoinResponse + WSIdentifyMessage. Client tracks hasIdentified, uses single-use wsToken on first identify and reconnectToken after that. Builds clean with tsc + tsup. Tests: 192/192. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 77d997f commit f1a8d30

4 files changed

Lines changed: 92 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Multi-agent audit ran 2026-04-22; produced 43 ranked findings (see internal `.lo
3535
- **F16** — Webhook dispatcher now claims pending rows atomically via `claim_pending_webhooks(batch_size)` (`UPDATE … WHERE status='pending' FOR UPDATE SKIP LOCKED SET status='in_flight', locked_at=NOW() RETURNING *`). Rows locked longer than 2 minutes are automatically reclaimed as crash recovery. Previously two overlapping dispatcher ticks could both read the same pending rows and double-deliver.
3636
- **F17** — Entry-fee split (pool increment + ticket insert/update) now happens in a single `record_entry_fee(wallet, amount, tickets)` RPC (migration `20260422_005_entry_fee_atomic.sql`). Previously the two independent RPC calls could leave state inconsistent on partial failure — orphan pool money with no matching ticket row, or ticket row with no matching pool contribution.
3737
- **F10** — Jackpot-win bookkeeping is now atomic: pool deduction + `payouts` rows for each winner commit together via `record_jackpot_win_and_deduct(payout, tier, game_id, winners_jsonb)` (migration `20260422_006_jackpot_win_atomic.sql`). Previously `deductFromJackpotPoolForWin` committed first and `savePendingPayouts` ran only inside `processJackpotPayouts` — a crash in between left pool money gone with no record of who was owed. Fallback to the two-step path on RPC failure keeps rollout safe.
38+
- **F03** — WebSocket paid-reconnect now requires a persistent `reconnectToken` issued at `/join`. Broadcasts (`queue_update`, pre-identify spectator welcome, post-identify own welcome) no longer expose raw `playerId`s of other queue entries. Previously a spectator who saw a peer's playerId in a queue broadcast could disconnect and re-identify as that playerId in the "paid reconnect" branch to hijack the seat. The SDK now saves the token on `join()` and auto-switches from `wsToken` (single-use first identify) to `reconnectToken` (subsequent reconnects).
3839

3940
Pre-Phase-12 items from this Unreleased window:
4041

packages/snakey-sdk/src/client.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export class SnakeyClient {
6969
private config: Required<SnakeyConfig>;
7070
private ws: WebSocketLike | null = null;
7171
private wsToken: string | null = null;
72+
// F03 — persistent reconnect token stashed from /join; used on any identify
73+
// after the single-use wsToken has been consumed.
74+
private reconnectToken: string | null = null;
75+
private hasIdentified = false;
7276
private playerId: string | null = null;
7377
private handlers: SnakeyEventHandlers = {};
7478
private reconnectAttempts = 0;
@@ -146,6 +150,8 @@ export class SnakeyClient {
146150
headers: extraHeaders,
147151
});
148152
this.wsToken = response.wsToken;
153+
this.reconnectToken = response.reconnectToken ?? null;
154+
this.hasIdentified = false;
149155

150156
if (this.config.autoConnect) {
151157
await this.connect(this.handlers);
@@ -161,6 +167,8 @@ export class SnakeyClient {
161167
headers: extraHeaders,
162168
});
163169
this.wsToken = response.wsToken;
170+
this.reconnectToken = response.reconnectToken ?? null;
171+
this.hasIdentified = false;
164172

165173
if (this.config.autoConnect) {
166174
await this.connect(this.handlers);
@@ -575,12 +583,21 @@ export class SnakeyClient {
575583

576584
this.ws.onopen = () => {
577585
this.reconnectAttempts = 0;
578-
// Send identify message
586+
// F03 — first identify uses the single-use wsToken; after that
587+
// it's consumed server-side and we need the persistent reconnect
588+
// token. `hasIdentified` flips to true on the first welcome.
579589
const identifyMsg: WSIdentifyMessage = {
580590
type: 'identify',
581591
playerId: this.playerId!,
582-
wsToken: this.wsToken!,
583592
};
593+
if (!this.hasIdentified && this.wsToken) {
594+
identifyMsg.wsToken = this.wsToken;
595+
} else if (this.reconnectToken) {
596+
identifyMsg.reconnectToken = this.reconnectToken;
597+
} else if (this.wsToken) {
598+
// Fallback: server that predates F03 still accepts wsToken.
599+
identifyMsg.wsToken = this.wsToken;
600+
}
584601
this.ws!.send(JSON.stringify(identifyMsg));
585602

586603
// Keepalive ping every 30s to prevent connection timeout
@@ -751,6 +768,9 @@ export class SnakeyClient {
751768
private handleMessage(msg: WSServerMessage): void {
752769
switch (msg.type) {
753770
case 'welcome':
771+
// F03 — first welcome confirms the single-use wsToken has been
772+
// consumed; any subsequent reconnect must use reconnectToken.
773+
this.hasIdentified = true;
754774
this.handlers.onWelcome?.(msg);
755775
break;
756776
case 'countdown':

packages/snakey-sdk/src/types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ export interface JoinResponse {
9696
jackpotPool: string;
9797
yourTickets: number;
9898
wsToken: string;
99+
/**
100+
* F03 — Persistent reconnect token. The initial `wsToken` is single-use
101+
* (consumed on the first identify); if the WS drops and the paid player
102+
* wants to reclaim their seat, the identify message must carry this
103+
* `reconnectToken` instead. Without it, spectators who learned a peer's
104+
* playerId (old behaviour) could previously hijack their queue slot.
105+
*/
106+
reconnectToken?: string;
99107
/** Phase 10 — true if a callbackUrl was registered */
100108
webhookRegistered?: boolean;
101109
/** Phase 10 — present only when the server generated the secret (agent did not supply one) */
@@ -365,7 +373,17 @@ export interface QuickPlayResult extends PlayResult {
365373
export interface WSIdentifyMessage {
366374
type: 'identify';
367375
playerId: string;
368-
wsToken: string;
376+
/**
377+
* Single-use token from the /join response. Required on the first
378+
* identify; consumed server-side on successful auth.
379+
*/
380+
wsToken?: string;
381+
/**
382+
* F03 — Persistent reconnect token from the /join response. Required
383+
* on any reconnect after the first identify (the single-use wsToken is
384+
* gone by then). Send one or the other, not neither.
385+
*/
386+
reconnectToken?: string;
369387
}
370388

371389
export interface WSWelcomeMessage {

src/server.js

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,8 +1439,10 @@ app.post('/join', joinLimiter, async (req, res) => {
14391439
const jackpotStatus = await jackpot.getJackpotStatus();
14401440
broadcast({
14411441
type: 'queue_update',
1442+
// F03 — do not broadcast raw playerId; spectators could previously
1443+
// copy a paid player's playerId from this payload and use it in a
1444+
// token-less reconnect. displayName + position is enough for the UI.
14421445
queue: queue.map(p => ({
1443-
playerId: p.playerId,
14441446
displayName: p.displayName,
14451447
position: queue.indexOf(p) + 1
14461448
})),
@@ -1461,8 +1463,19 @@ app.post('/join', joinLimiter, async (req, res) => {
14611463
recordWalletJoin(walletAddress);
14621464
}
14631465

1464-
// Generate WebSocket auth token
1466+
// Generate WebSocket auth token.
1467+
// F03 — Also stash a persistent reconnect token on the queue entry.
1468+
// Previously any paid player's playerId was sufficient for a reconnect
1469+
// (isPaidReconnect allowed token-less identify), and queue_update
1470+
// broadcasts leaked those playerIds to every spectator. Now reconnects
1471+
// must present a token the server only gave to the original payer.
14651472
const wsToken = generateWsAuthToken(playerId);
1473+
const reconnectToken = crypto.randomBytes(32).toString('hex');
1474+
const queuedForReconnect = queue.find(p => p.playerId === playerId);
1475+
if (queuedForReconnect) {
1476+
queuedForReconnect.reconnectToken = reconnectToken;
1477+
syncQueueToDB('join-reconnect-token');
1478+
}
14661479

14671480
const allTickets = await getAllJackpotTickets();
14681481

@@ -1476,6 +1489,7 @@ app.post('/join', joinLimiter, async (req, res) => {
14761489
jackpotPool: `$${jackpotStatus.currentPool.toFixed(2)}`,
14771490
yourTickets: walletAddress ? (allTickets.find(t => t.wallet_address === walletAddress)?.tickets || 1) : 0,
14781491
wsToken, // Token for authenticated WebSocket connection
1492+
reconnectToken, // F03 — presented in the identify message after a drop
14791493
// Phase 10 — passive-agent extras
14801494
webhookRegistered: !!callbackUrl,
14811495
webhookSecret: callbackUrl ? resolvedWebhookSecret : undefined // only returned if server generated it
@@ -1790,8 +1804,8 @@ app.post('/join/bulk', joinLimiter, async (req, res) => {
17901804
const jackpotStatus = await jackpot.getJackpotStatus();
17911805
broadcast({
17921806
type: 'queue_update',
1807+
// F03 — see above: no raw playerId in broadcasts.
17931808
queue: queue.map((p, idx) => ({
1794-
playerId: p.playerId,
17951809
displayName: p.displayName,
17961810
position: idx + 1
17971811
})),
@@ -2592,10 +2606,12 @@ wss.on('connection', (ws, req) => {
25922606
type: 'welcome',
25932607
gameStatus: currentGame?.gameStatus || 'IDLE',
25942608
queueSize: queue.length,
2595-
queue: queue.map(p => ({
2596-
playerId: p.playerId,
2609+
// F03 — pre-identify, the caller is treated as a spectator. Do not leak
2610+
// paid players' playerIds; displayName + colorIndex is enough for the UI.
2611+
queue: queue.map((p, idx) => ({
25972612
displayName: p.displayName,
2598-
colorIndex: p.colorIndex
2613+
colorIndex: p.colorIndex,
2614+
position: idx + 1
25992615
})),
26002616
countdown: countdownTimer !== null ? countdownSecondsLeft : null,
26012617
maxPlayers: MAX_PLAYERS,
@@ -2614,26 +2630,35 @@ wss.on('connection', (ws, req) => {
26142630
const isInQueue = !!queuedPlayerForAuth;
26152631

26162632
if (isInQueue) {
2617-
// Check if this is a paid player reconnecting (they already authenticated)
2618-
const isPaidReconnect = queuedPlayerForAuth.gamePoolContribution > 0 && queuedPlayerForAuth.disconnected;
2619-
2620-
if (isPaidReconnect) {
2621-
// Paid player reconnecting - allow without token, they already paid
2633+
// F03 — Paid-reconnect flow now requires the reconnect token
2634+
// issued at /join (or re-issued during initial welcome). The old
2635+
// token-less isPaidReconnect branch let any spectator who had
2636+
// learned a paid player's playerId from a queue_update broadcast
2637+
// silently hijack that seat. The broadcast is also scrubbed of
2638+
// playerId below, but the token check is the real defense.
2639+
const providedReconnectToken = msg.reconnectToken || '';
2640+
const storedReconnectToken = queuedPlayerForAuth.reconnectToken || '';
2641+
const tokenMatchesReconnect = !!providedReconnectToken
2642+
&& !!storedReconnectToken
2643+
&& providedReconnectToken.length === storedReconnectToken.length
2644+
&& crypto.timingSafeEqual(Buffer.from(providedReconnectToken), Buffer.from(storedReconnectToken));
2645+
2646+
if (tokenMatchesReconnect) {
26222647
queuedPlayerForAuth.disconnected = false;
26232648
queuedPlayerForAuth.disconnectedAt = null;
2649+
syncQueueToDB('ws-reconnect');
26242650
authenticated = true;
2625-
log.info({ playerId: msg.playerId }, 'Paid player reconnected to queue');
2651+
log.info({ playerId: msg.playerId }, 'Paid player reconnected to queue (reconnect token OK)');
26262652
} else if (!msg.wsToken || !verifyWsAuthToken(msg.playerId, msg.wsToken)) {
2627-
// New player or unpaid - must provide valid token
2628-
log.warn({ playerId: msg.playerId }, 'WebSocket auth failed - invalid token');
2653+
log.warn({ playerId: msg.playerId, hasReconnect: !!providedReconnectToken }, 'WebSocket auth failed - invalid token');
26292654
ws.send(JSON.stringify({
26302655
type: 'error',
26312656
error: 'Invalid or expired authentication token'
26322657
}));
26332658
ws.close(4001, 'Unauthorized');
26342659
return;
26352660
} else {
2636-
// FIX 4: Delete token after successful verification (single-use)
2661+
// Single-use wsToken consumed on first identify.
26372662
wsAuthTokens.delete(msg.playerId);
26382663
authenticated = true;
26392664
}
@@ -2643,15 +2668,21 @@ wss.on('connection', (ws, req) => {
26432668
connections.set(playerId, { ws, displayName: msg.displayName, authenticated });
26442669
log.debug({ playerId, displayName: msg.displayName, authenticated }, 'Player identified');
26452670

2646-
// Send current state including full queue
2671+
// Send current state including full queue. F03 — only the caller's
2672+
// own playerId is echoed back; other entries expose only displayName
2673+
// + colorIndex so a compromised WS client can't learn neighbors'
2674+
// playerIds.
26472675
ws.send(JSON.stringify({
26482676
type: 'welcome',
26492677
gameStatus: currentGame?.gameStatus || 'IDLE',
26502678
queueSize: queue.length,
2651-
queue: queue.map(p => ({
2652-
playerId: p.playerId,
2679+
yourPlayerId: playerId,
2680+
queue: queue.map((p, idx) => ({
26532681
displayName: p.displayName,
2654-
colorIndex: p.colorIndex
2682+
colorIndex: p.colorIndex,
2683+
position: idx + 1,
2684+
// echo own playerId back so the frontend can identify itself
2685+
playerId: p.playerId === playerId ? p.playerId : undefined
26552686
})),
26562687
countdown: countdownTimer !== null ? countdownSecondsLeft : null,
26572688
maxPlayers: MAX_PLAYERS,

0 commit comments

Comments
 (0)