Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions chessServer/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
NODE_ENV=development

# Port the socket/HTTP server listens on. Defaults to 3001 if unset.
PORT=3001

# Comma-separated list of origins allowed to connect (CORS + Socket.IO).
# Falls back to a hardcoded dev/prod allowlist in src/index.js if unset.
# Required in production (one of CORS_ORIGIN or ALLOWED_ORIGINS).
CORS_ORIGIN=http://localhost:3000,http://localhost:3002
ALLOWED_ORIGINS=

# Base URL of middlewareNode, used to verify PvP game identity
# (GET /challenge/game/:gameId) and to report finished game results
# (POST /internal/gameResults). Required in production.
MIDDLEWARE_URL=http://localhost:8000

# Shared secret the chess server authenticates with when reporting a PvP
# result to the middleware (X-Service-Key header). Must match the same
# value configured on middlewareNode. Required in production. See the PvP
# results plan (v2), T5 — generate one random 32-byte value and store it as
# a secret on both services, not in source control.
CHESS_SERVICE_KEY=
96 changes: 52 additions & 44 deletions chessServer/src/managers/EventHandlers.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const GameManager = require("./GameManager");
const buildResultRequest = require("../reporting/resultRequest");

const gameManager = new GameManager();

Expand All @@ -10,55 +11,35 @@ const gameManager = new GameManager();
* balance to credit — the coin idea was dropped in favour of a separate
* leaderboard stat). See documentation/student-vs-student-design.md §7.
*
* Authenticated with CHESS_SERVICE_KEY, not a player's token — the chess
* server reports with its own identity, and the middleware checks the result
* against the PvpGame it saved when the challenge was accepted. See the PvP
* results plan (v2), target design point 4.
*
* Idempotency is the middleware's job, keyed on `gameId`: a reconnect, a retry,
* or both clients reporting the same game is a no-op there. This side just
* reports once per decided game and tolerates failure — a lost report costs one
* game's stats, it must never break the players' "game over" experience.
*
* Mirrors the existing activity pattern in the "move" handler, which PUTs to
* `${MIDDLEWARE_URL}/activities/:username/activity` with a Bearer credential.
*
* @param {Object} game - the finished game (supplies gameId and both players)
* @param {Object} outcome - { over, reason, winnerUsername?, loserUsername? }
* @param {string} [credentials] - Bearer token of a player in this game
*/
const reportGameResult = async (game, outcome, credentials) => {
const reportGameResult = async (game, outcome) => {
if (!process.env.MIDDLEWARE_URL) {
console.log("[gameResults] MIDDLEWARE_URL unset — skipping report");
return;
}
// The middleware only accepts a report from a player in the game, so fall
// back to a seated player's token when the ending event carried none
// (resign and disconnect have no payload).
const token = credentials || (game.players || []).map((p) => p.credentials).find(Boolean);
if (!token) {
console.log(`[gameResults] no credentials for game ${game.gameId} — skipping report`);
if (!process.env.CHESS_SERVICE_KEY) {
console.log(`[gameResults] CHESS_SERVICE_KEY unset — skipping report for ${game.gameId}`);
return;
}

const isDraw = !outcome.winnerUsername;
const body = isDraw
? {
gameId: game.gameId,
result: "draw",
reason: "draw",
players: game.players.map((p) => p.username),
}
: {
gameId: game.gameId,
result: "win",
reason: outcome.reason,
winnerUsername: outcome.winnerUsername,
loserUsername: outcome.loserUsername,
};
const { path, headers, body } = buildResultRequest(game, outcome, process.env.CHESS_SERVICE_KEY);

try {
const response = await fetch(`${process.env.MIDDLEWARE_URL}/gameResults`, {
const response = await fetch(`${process.env.MIDDLEWARE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authentication: `Bearer ${token}`,
},
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
Expand All @@ -81,9 +62,8 @@ const reportGameResult = async (game, outcome, credentials) => {
* @param {Object} game - the finished game
* @param {Object} outcome - { over, reason, winnerUsername?, loserUsername? }
* @param {Server} io
* @param {string} [credentials] - Bearer token of the reporting client
*/
const emitGameOver = async (game, outcome, io, credentials) => {
const emitGameOver = async (game, outcome, io) => {
const payload = JSON.stringify(outcome);
[game.student.id, game.mentor.id].forEach((id) => {
if (id) io.to(id).emit("gameover", payload);
Expand All @@ -92,7 +72,7 @@ const emitGameOver = async (game, outcome, io, credentials) => {
// A draw still counts as a played game, so report it too — only the
// opponent-never-joined case (no usernames at all) is skipped.
if (game.isPvp && (outcome.winnerUsername || outcome.reason === "draw")) {
await reportGameResult(game, outcome, credentials);
await reportGameResult(game, outcome);
}
};

Expand Down Expand Up @@ -134,20 +114,48 @@ const registerSocketHandlers = (socket, io) => {

/**
* Handles creating or joining a student-vs-student (PvP) game by gameId.
* The gameId + both usernames come from an accepted challenge (middleware).
* Expected payload: { gameId, challenger, opponent, username, credentials }
*
* Identity is verified against the middleware, never trusted from the
* client: the chess server calls GET /challenge/game/:gameId with the
* joining player's own token, and seats the player under the `you` the
* middleware returns (derived from that token), not the `username` field
* the client sent. If the two differ, the client is lying about who it
* is — the join is rejected rather than silently corrected, so a client
* bug surfaces instead of being hidden. `white`/`black` likewise come
* only from that response. See the PvP results plan (v2), target design
* point 2 and T4.
*
* Expected payload: { gameId, username, credentials }
*/
socket.on("newpvpgame", (msg) => {
socket.on("newpvpgame", async (msg) => {
try {
const parsed = JSON.parse(msg);
const { gameId, username, credentials } = parsed;

if (!process.env.MIDDLEWARE_URL) {
throw new Error("MIDDLEWARE_URL unset — cannot verify game");
}

const response = await fetch(`${process.env.MIDDLEWARE_URL}/challenge/game/${gameId}`, {
headers: { Authorization: `Bearer ${credentials}` },
});
if (!response.ok) {
socket.emit("gameerror", `Unable to verify game (${response.status})`);
return;
}
const { you, white, black } = await response.json();

if (username !== you) {
socket.emit("gameerror", "username does not match your login");
return;
}

const result = gameManager.createOrJoinPvpGame({
gameId: parsed.gameId,
challenger: parsed.challenger,
opponent: parsed.opponent,
username: parsed.username,
socketId: socket.id,
credentials: parsed.credentials
gameId,
username: you,
white,
black,
socketId: socket.id
});

socket.emit(
Expand Down Expand Up @@ -222,7 +230,7 @@ const registerSocketHandlers = (socket, io) => {
if (outcome && outcome.over) {
const game = gameManager.getGameBySocketId(socket.id);
if (game) {
await emitGameOver(game, outcome, io, credentials);
await emitGameOver(game, outcome, io);
}
}
if(!computerMove && credentials) {
Expand Down
55 changes: 28 additions & 27 deletions chessServer/src/managers/GameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,29 +90,33 @@ class GameManager {
* `game.isPvp` marks that the slot NAMES carry no meaning in this game.
* See student-vs-student-design.md §5a.
*
* The challenger takes white. Whichever client connects first creates the
* game; the second joins it by gameId. Reconnecting with the same username
* reclaims the original seat and color rather than creating a new game.
* `username`, `white` and `black` must come from the middleware's
* GET /challenge/game/:gameId response — never from the client socket
* message directly. The caller (EventHandlers' `newpvpgame` handler)
* verifies the joining socket against that response before calling this,
* so by the time this method runs, identity has already been checked.
* Whichever client connects first creates the game; the second joins it
* by gameId. Reconnecting with the same username reclaims the original
* seat and color rather than creating a new game.
*
* Each seat also carries the joining client's `credentials` (bearer token).
* The result report at game end is sent to the middleware as one of the two
* players, and resign/disconnect endings are triggered by a socket event
* that carries no body — so the token has to be captured at join time.
* Seats no longer carry a player token: results are reported to the
* middleware with CHESS_SERVICE_KEY, not a player's bearer token, so
* there is nothing to capture here. See the PvP results plan (v2), T4.
*
* @param {Object} param0 - Contains gameId, challenger, opponent, username, socketId, credentials
* @param {Object} param0 - Contains gameId, username, white, black, socketId
* @returns {Object} Game object, assigned color, and new game status
*/
createOrJoinPvpGame({ gameId, challenger, opponent, username, socketId, credentials }) {
createOrJoinPvpGame({ gameId, username, white, black, socketId }) {
if (!gameId) {
throw new Error("A gameId is required to join a student-vs-student game!");
}
if (!challenger || !opponent) {
throw new Error("Both challenger and opponent usernames are required!");
if (!white || !black) {
throw new Error("Both white and black usernames are required!");
}
if (challenger === opponent) {
if (white === black) {
throw new Error("A student cannot challenge themselves!");
}
if (username !== challenger && username !== opponent) {
if (username !== white && username !== black) {
throw new Error("You are not a player in this game!");
}

Expand All @@ -125,30 +129,27 @@ class GameManager {
throw new Error("You are not a player in this game!");
}
seat.id = socketId;
if (credentials) seat.credentials = credentials;
return { game, color: seat.color, newGame: false };
}

// First player in creates the game; both seats are known upfront from
// the accepted challenge, so the opponent's seat just waits for a socket.
// the accepted challenge, so the second player's seat just waits for a socket.
const board = new Chess();
const challengerPlayer = {
username: challenger,
id: username === challenger ? socketId : null,
credentials: username === challenger ? credentials : null,
const whitePlayer = {
username: white,
id: username === white ? socketId : null,
color: "white"
};
const opponentPlayer = {
username: opponent,
id: username === opponent ? socketId : null,
credentials: username === opponent ? credentials : null,
const blackPlayer = {
username: black,
id: username === black ? socketId : null,
color: "black"
};

const newGame = {
student: challengerPlayer,
mentor: opponentPlayer,
players: [challengerPlayer, opponentPlayer],
student: whitePlayer,
mentor: blackPlayer,
players: [whitePlayer, blackPlayer],
gameId,
isPvp: true,
boardState: board,
Expand All @@ -159,7 +160,7 @@ class GameManager {

return {
game: newGame,
color: username === challenger ? "white" : "black",
color: username === white ? "white" : "black",
newGame: true
};
}
Expand Down
43 changes: 43 additions & 0 deletions chessServer/src/reporting/resultRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Builds the chess server's game-result report to the middleware.
*
* A pure function with no `require` calls, so middlewareNode's own tests can
* import this file directly by relative path and assert against the exact
* request it produces (see middlewareNode/tests/contract.chessServerReport.test.js,
* the PvP results plan v2's T4b). If the header name or the path ever drifts
* between the two services, that contract test fails in CI instead of both
* sides quietly agreeing with themselves. See target design point 5.
*
* @param {Object} game - the finished game; supplies gameId and both players
* @param {Object} outcome - { reason, winnerUsername?, loserUsername? }
* @param {string} key - the shared secret (CHESS_SERVICE_KEY)
* @returns {{ path: string, headers: Object, body: Object }}
*/
function buildResultRequest(game, outcome, key) {
const isDraw = !outcome.winnerUsername;
const body = isDraw
? {
gameId: game.gameId,
result: "draw",
reason: "draw",
players: game.players.map((p) => p.username),
}
: {
gameId: game.gameId,
result: "win",
reason: outcome.reason,
winnerUsername: outcome.winnerUsername,
loserUsername: outcome.loserUsername,
};

return {
path: "/internal/gameResults",
headers: {
"Content-Type": "application/json",
"X-Service-Key": key,
},
body,
};
}

module.exports = buildResultRequest;
Loading
Loading