From 81b7b619ffbdb494951d9bce313baec6b4e08e92 Mon Sep 17 00:00:00 2001 From: Michael Harkins Date: Mon, 29 Jun 2026 07:18:44 -0500 Subject: [PATCH] fix(matchmaking): self-heal a missing session instead of 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matchmaking routes looked up the player's in-memory session with a strict getSession() and threw "Session not found" (401) when it was absent. Sessions are in-memory only, so they are lost on a server restart and reaped by the EMQX disconnect webhook for non-lobby players, while the player's JWT is stateless and outlives them. The result: a still-authenticated player is permanently stranded with no recovery path (the client never re-auths on a valid token). Route the lookup through a shared ensureSession(), which rebuilds the session from the durable player record when it is missing — the same self-healing pattern the auth endpoints already use. A player who does not exist in the DB at all is genuinely unknown and still rejected (404). - add ensure-session.ts (reuses the existing dbPlayerToSessionInit mapper) - use ensureSession() in /queue, /matches/:id/start, /matches/:id/result - export dbPlayerToSessionInit so the helper can share it (no duplication) - add regression tests: heals a known player to 200, rejects unknown 404 Refs #32 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/features/auth/auth.service.ts | 2 +- .../src/features/auth/ensure-session.ts | 25 +++++ .../features/matchmaking/matchmaking.route.ts | 11 +- .../routes/session-not-found.repro.test.ts | 105 ++++++++++++++++++ 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/features/auth/ensure-session.ts create mode 100644 apps/server/src/tests/routes/session-not-found.repro.test.ts diff --git a/apps/server/src/features/auth/auth.service.ts b/apps/server/src/features/auth/auth.service.ts index 7255f56..57fe664 100644 --- a/apps/server/src/features/auth/auth.service.ts +++ b/apps/server/src/features/auth/auth.service.ts @@ -52,7 +52,7 @@ function sessionAndToken(session: PlayerSession): SessionAndToken { return { session, token: signSessionJwt(session) } } -function dbPlayerToSessionInit( +export function dbPlayerToSessionInit( dbPlayer: PlayerRecord, overrides: Partial = {}, ): SessionInit { diff --git a/apps/server/src/features/auth/ensure-session.ts b/apps/server/src/features/auth/ensure-session.ts new file mode 100644 index 0000000..f13dc4d --- /dev/null +++ b/apps/server/src/features/auth/ensure-session.ts @@ -0,0 +1,25 @@ +import { createSession, getSession, type PlayerSession } from '../../state/index.js' +import { findPlayerById } from '../../infrastructure/gateways/player.gateway.js' +import { AppError } from '../../shared/utils/errors.js' +import { dbPlayerToSessionInit } from './auth.service.js' + +/** + * Return the player's in-memory session, rebuilding it from the durable player + * record when it is missing. + * + * Sessions are in-memory only, so they are lost on a server restart or when the + * EMQX disconnect webhook reaps a non-lobby player — yet the player's JWT is + * stateless and outlives them. Without this, any session-gated route would 401 + * a still-authenticated player with no way to recover (see issue #32). Falling + * back to the DB lets a known player transparently continue; a player who does + * not exist in the DB at all is genuinely unknown and is rejected. + */ +export async function ensureSession(playerId: string): Promise { + const existing = getSession(playerId) + if (existing) return existing + + const dbPlayer = await findPlayerById(playerId) + if (!dbPlayer) throw new AppError('Player not found', 404) + + return createSession(dbPlayer.steamName, dbPlayerToSessionInit(dbPlayer)) +} diff --git a/apps/server/src/features/matchmaking/matchmaking.route.ts b/apps/server/src/features/matchmaking/matchmaking.route.ts index 5ae7b82..81c15a0 100644 --- a/apps/server/src/features/matchmaking/matchmaking.route.ts +++ b/apps/server/src/features/matchmaking/matchmaking.route.ts @@ -1,6 +1,6 @@ import { Router } from 'express' import { authenticate } from '../../middleware/authenticate.js' -import { getSession } from '../../state/index.js' +import { ensureSession } from '../auth/ensure-session.js' import type { PlacementEntry } from '../../shared/types/index.js' import { AppError } from '../../shared/utils/errors.js' import { assertCanPlay } from '../../shared/utils/access.js' @@ -32,8 +32,7 @@ export function createMatchmakingRouter(service: MatchmakingService): Router { router.post('/queue', async (req, res, next) => { try { - const session = getSession(req.player!.playerId) - if (!session) throw new AppError('Session not found', 401) + const session = await ensureSession(req.player!.playerId) assertCanPlay(session) const { modId, gameMode, minPlayers, maxPlayers } = req.body @@ -84,8 +83,7 @@ export function createMatchmakingRouter(service: MatchmakingService): Router { router.post('/matches/:matchId/start', async (req, res, next) => { try { - const session = getSession(req.player!.playerId) - if (!session) throw new AppError('Session not found', 401) + const session = await ensureSession(req.player!.playerId) await service.markRunStart(session, req.params.matchId) res.status(204).send() @@ -96,8 +94,7 @@ export function createMatchmakingRouter(service: MatchmakingService): Router { router.post('/matches/:matchId/result', async (req, res, next) => { try { - const session = getSession(req.player!.playerId) - if (!session) throw new AppError('Session not found', 401) + const session = await ensureSession(req.player!.playerId) const { matchId } = req.params const { placements } = req.body as { placements: PlacementEntry[] } diff --git a/apps/server/src/tests/routes/session-not-found.repro.test.ts b/apps/server/src/tests/routes/session-not-found.repro.test.ts new file mode 100644 index 0000000..c9dd094 --- /dev/null +++ b/apps/server/src/tests/routes/session-not-found.repro.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import request from 'supertest' +import { createTestApp } from './app.js' +import { signJwt } from '../../features/auth/jwt.js' +import { createSession, sessions } from '../../state/index.js' +import { queues, playerQueues } from '../../state/matchmaking.js' +import { findPlayerById } from '../../infrastructure/gateways/player.gateway.js' +import type { PlayerRecord } from '../../contracts/IPlayerRepository.js' + +const app = createTestApp() + +// A server restart wipes ALL in-memory state at once: sessions AND the queue +// maps. (The JWT is stateless and survives on the client.) +function simulateRestart(): void { + sessions.clear() + queues.clear() + playerQueues.clear() +} + +const QUEUE_BODY = { modId: 'mod1', gameMode: 'mode1', minPlayers: 2, maxPlayers: 4 } + +function dbPlayer(id: string, steamName: string): PlayerRecord { + return { + id, + steamIdHash: 'hash', + discordIdHash: null, + discordUsername: null, + useDiscordName: false, + preferredJoker: 'j_joker', + privileges: [], + steamName, + chatEnabled: false, + chatBlocked: false, + tosAcceptedVersion: 0, + } as PlayerRecord +} + +// Regression coverage for issue #32. +// +// A valid JWT outlives its in-memory session (the session Map is wiped on a +// server restart, and the EMQX disconnect webhook reaps a non-lobby player). +// Matchmaking used a strict getSession() that 401'd such a player with no way +// to recover. The fix routes the lookup through ensureSession(), which rebuilds +// the session from the durable player record. +describe('issue #32: matchmaking self-heals a lost session', () => { + it('queues normally while the in-memory session exists', async () => { + const playerId = 'p-live' + createSession('Alice', { id: playerId }) + const token = signJwt({ playerId, steamName: 'Alice' }) + + const res = await request(app) + .post('/api/matchmaking/queue') + .set('Authorization', `Bearer ${token}`) + .send(QUEUE_BODY) + + expect(res.status).toBe(200) + }) + + it('rebuilds the session from the DB and re-queues with the SAME token after the map is wiped', async () => { + const playerId = 'p-known' + createSession('Alice', { id: playerId }) + const token = signJwt({ playerId, steamName: 'Alice' }) + + // token works right now + const ok = await request(app) + .post('/api/matchmaking/queue') + .set('Authorization', `Bearer ${token}`) + .send(QUEUE_BODY) + expect(ok.status).toBe(200) + + // session reaped (restart / disconnect webhook). JWT is unchanged. + simulateRestart() + + // the player still exists in the durable store + vi.mocked(findPlayerById).mockResolvedValueOnce(dbPlayer(playerId, 'Alice')) + + const res = await request(app) + .post('/api/matchmaking/queue') + .set('Authorization', `Bearer ${token}`) + .send(QUEUE_BODY) + + // Before the fix this was 401 "Session not found". Now it self-heals. + expect(res.status).toBe(200) + // and the session really was rebuilt in memory + expect(sessions.has(playerId)).toBe(true) + }) + + it('rejects with 404 when the player does not exist in the DB at all', async () => { + const playerId = 'p-ghost' + createSession('Ghost', { id: playerId }) + const token = signJwt({ playerId, steamName: 'Ghost' }) + + simulateRestart() + // default gateway mock already resolves findPlayerById -> null + vi.mocked(findPlayerById).mockResolvedValueOnce(null) + + const res = await request(app) + .post('/api/matchmaking/queue') + .set('Authorization', `Bearer ${token}`) + .send(QUEUE_BODY) + + expect(res.status).toBe(404) + expect(res.body.error ?? res.body.message ?? '').toMatch(/player not found/i) + }) +})