Absence is not evidence: the seat wallet trusts only verdicts

The games-list reply pruned any wallet seat the server did not vouch
for — so one poll against a restarting (or wrong) server silently
deleted live credentials, the second seat-burning path this session's
server-squatting exposed. myGames now returns explicit verdicts:
a seat is voided only when its room EXISTS and refused that exact
token. Absent rooms earn no verdict and absent seats survive; the
wallet caps by age at fifty instead of by trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-26 14:03:29 -04:00
co-authored by Claude Fable 5
parent 72148dd7c2
commit 3d2bf17585
2 changed files with 23 additions and 15 deletions
+11 -3
View File
@@ -732,18 +732,26 @@ wss.on("connection", (socket) => {
break; break;
} }
case "myGames": { case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats. // {seats: [{roomId, name, token}]} -> summaries for valid seats,
// plus explicit verdicts on seats PROVEN dead: the room exists
// and refused this exact token. A room this server simply does
// not know earns no verdict — absence is not evidence (a
// restarting or wrong server knows nothing about anything).
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const games = []; const games = [];
const voided: string[] = [];
for (const seat of seats) { for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue; if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? "")); const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue; if (!room) continue;
const name = String(seat.name ?? ""); const name = String(seat.name ?? "");
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue; if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
voided.push(`${room.id}:${name}`);
continue;
}
games.push(summarize(room, name)); games.push(summarize(room, name));
} }
send(socket, { type: "games", games }); send(socket, { type: "games", games, voided });
break; break;
} }
default: default:
+9 -9
View File
@@ -491,19 +491,19 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g); if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn); this.lastYourTurn.set(key, g.yourTurn);
} }
// A seat the server answered for but did not list is gone — the // Prune ONLY seats the server proved dead: the room exists and
// room was deleted (or the seat's token voided). Quietly drop it // refused this exact token. A seat merely ABSENT from the reply
// from the ledger rather than showing "unreachable" forever. // stays — a restarting server, a stale restore, or the wrong
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask; // backend all answer with ignorance, and treating ignorance as
// never prune blind past that cap.) // deletion once cost a live player his seat mid-game. The
if (this.seats.length <= 50) { // wallet is capped by age instead of by trust.
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`)); const voided = new Set((msg.voided as string[] | undefined) ?? []);
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`)); let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) { if (kept.length !== this.seats.length) {
this.seats = kept; this.seats = kept;
saveSeats(this.seats); saveSeats(this.seats);
} }
}
break; break;
} }
case "error": case "error":