Skip to content

Hide other players' letters and the bag from game payloads - #53

Open
paskal wants to merge 4 commits into
masterfrom
fix/hide-player-letters
Open

Hide other players' letters and the bag from game payloads#53
paskal wants to merge 4 commits into
masterfrom
fix/hide-player-letters

Conversation

@paskal

@paskal paskal commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every GAME_UPDATED emit and the unauthenticated GET /game/:id returned the full Game row, which includes letters — every player's hand plus the pot (the bag contents and its exact draw order). Any player (or any anonymous visitor hitting GET /game/:id) could read opponents' tiles and the upcoming draw order straight from the payload, making the game trivially cheatable.

Fix

Added services/sanitizeGame.ts:

  • sanitizeGame(game, userId) returns a plain object where letters contains only the recipient's own hand; the pot is replaced by an array of the same length filled with "" (the client only reads letters.pot.length); previousLetters/putLetters are included only for the player who made the turn currently on the board. Anonymous recipients (userId === null) get no hand.
  • emitGameUpdated(server, gameId, game) iterates the game room and emits GAME_UPDATED sanitised per socket, using each socket's playerId.

Wired it into every place that previously broadcast or returned a raw game: the turn/approve/undo/change/start routes now call emitGameUpdated, GET /game/:id sanitises with an optionally-authenticated user id (new getRequestUserId helper, so logged-in players still get their hand while spectators get none), and the socket-login path in handlers.ts sanitises with the authenticated user's id.

Client compatibility: the client reads game.letters[user.id] for its own hand and game.letters.pot.length for the bag counter only, so the masked pot and per-recipient hand keep it working unchanged.

@paskal
paskal requested a review from Ksinia as a code owner July 3, 2026 23:43
@paskal
paskal requested a review from Copilot July 3, 2026 23:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents game-state payloads from leaking hidden information (other players’ hands and the exact bag/draw order) by sanitizing letters per recipient before sending GAME_UPDATED updates or serving GET /game/:id.

Changes:

  • Added sanitizeGame(game, userId) to remove other players’ hands and mask letters.pot while preserving pot length.
  • Added emitGameUpdated(server, gameId, game) to emit per-socket sanitized GAME_UPDATED messages in the game room.
  • Updated game routes and socket login flow to use sanitized game payloads (including optional auth for GET /game/:id).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
services/sanitizeGame.ts Introduces per-recipient game sanitization and per-socket GAME_UPDATED emission.
routers/game.ts Switches game update broadcasts and GET /game/:id to send sanitized game payloads (with optional auth parsing).
handlers.ts Sanitizes the initial game payload sent to a socket after successful login.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread handlers.ts Outdated
@paskal
paskal force-pushed the fix/hide-player-letters branch from 2693a3a to 6e6b14a Compare July 3, 2026 23:59
@paskal
paskal requested a review from Copilot July 4, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread routers/game.ts
Comment thread services/sanitizeGame.ts Outdated
Comment thread services/sanitizeGame.ts
@paskal
paskal force-pushed the fix/hide-player-letters branch from 6e6b14a to c787f47 Compare July 4, 2026 01:05
@paskal
paskal requested a review from Copilot July 4, 2026 09:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread services/sanitizeGame.ts
@paskal
paskal force-pushed the fix/hide-player-letters branch from c787f47 to 4eb28e1 Compare July 4, 2026 09:30
@paskal
paskal requested a review from Copilot July 4, 2026 09:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread services/sanitizeGame.ts Outdated
@paskal
paskal force-pushed the fix/hide-player-letters branch from 4eb28e1 to 4c7f944 Compare July 4, 2026 09:38
@paskal
paskal requested a review from Copilot July 4, 2026 09:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread routers/game.ts
Comment thread routers/game.ts
Comment thread services/sanitizeGame.ts Outdated
* players' letters are removed. Anonymous recipients get no hand.
*/
export const sanitizeGame = (game: Game, userId: number | null) => {
const json = game.toJSON();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this line?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It converts the Sequelize model instance into a plain object. We need that to safely spread it ({ ...json, letters, ... }) and read fields like json.letters / json.turnOrder: spreading a Sequelize instance directly doesn't expose the column values (they live behind getters / dataValues), so without toJSON() the rebuilt object would be missing the game data.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't understand why we didn't need this before. Also, does it mean that this JSON object is any? Can we type it more strictly?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why it wasn't needed before: previously we passed the raw Sequelize game instance straight into socket.emit/res.send, and the JSON serialization that socket.io/Express do calls the instance's toJSON() automatically on the way out - so the plain-object conversion always happened, just implicitly at send time. Now we rebuild the object server-side to mask the letters, so we need a plain, spreadable object up front, hence the explicit toJSON().

On typing: you're right, it was effectively any. Fixed in 428a3c2 - it's now cast to a GameJson interface where the fields we read (letters, turnOrder, turn, previousLetters, putLetters) are typed and a string index signature carries the rest of the game fields through the spread. sourceLetters is typed { [key: string]: string[] } too.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between GameJson and Sequelize game type?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and both done in d8b9661.

Difference between GameJson and the Sequelize Game type: Game is the model instance type - it carries the Sequelize machinery (update, save, toJSON, dataValues, association setters). game.toJSON() returns only the plain column values, no methods, so typing that result as Game would be a lie (you can't call .update() on it), and we rebuild it into a fresh plain object anyway.

Stricter: you're right that my hand-written interface was too loose - the [key: string]: unknown index signature meant any typo or unknown field passed silently. It's now derived from the model:

type GameJson = InferAttributes<Game>;
type GameLetters = GameJson["letters"];

InferAttributes (sequelize 6.37) gives exactly the model's data attributes and drops the methods, so there's a single source of truth and no index signature - json.somethingUnknown is now a compile error. letters uses the model's own type too, so the hand is keyed by the numeric user id like the model declares (same JSON on the wire).

Comment above the type removed.

Comment thread handlers.ts Outdated
Previously, every GAME_UPDATED emit and the unauthenticated GET /game/:id
returned the full Game row, including all players' hands, the pot contents
and its draw order, so anyone could cheat by inspecting the payload.

After this change, game payloads are sanitized per recipient: each player
receives only their own hand, the pot is masked to an array of the same
length (the client only uses its size), and previousLetters/putLetters are
included only for the player who made the current turn. GET /game/:id
supports optional authentication so logged-in players still receive their
hand; anonymous spectators get none.
@paskal
paskal force-pushed the fix/hide-player-letters branch from 4c7f944 to 4e49989 Compare July 4, 2026 12:09
paskal added 3 commits July 4, 2026 13:54
Addresses review: apply the shared getBearerToken helper to the other
places that parsed the Authorization header inline, so the confirm-email
and profile routes no longer duplicate the Bearer parsing.
Addresses review: game.toJSON() was implicitly any. Type it as a GameJson
interface - the fields sanitizeGame reads (letters, turnOrder, turn,
previousLetters, putLetters) are typed, with an index signature carrying
the remaining game fields through the spread.
Addresses review: GameJson was hand-written with an index signature, so
unknown fields passed silently. It is now InferAttributes<Game>, derived
from the model, and the letters object uses the model's own type.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants