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;
}
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 games = [];
const voided: string[] = [];
for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue;
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));
}
send(socket, { type: "games", games });
send(socket, { type: "games", games, voided });
break;
}
default:
+9 -9
View File
@@ -491,19 +491,19 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn);
}
// A seat the server answered for but did not list is gone — the
// room was deleted (or the seat's token voided). Quietly drop it
// from the ledger rather than showing "unreachable" forever.
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
// never prune blind past that cap.)
if (this.seats.length <= 50) {
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
// Prune ONLY seats the server proved dead: the room exists and
// refused this exact token. A seat merely ABSENT from the reply
// stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and treating ignorance as
// deletion once cost a live player his seat mid-game. The
// wallet is capped by age instead of by trust.
const voided = new Set((msg.voided as string[] | undefined) ?? []);
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) {
this.seats = kept;
saveSeats(this.seats);
}
}
break;
}
case "error":