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
2 changes: 1 addition & 1 deletion apps/server/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function sessionAndToken(session: PlayerSession): SessionAndToken {
return { session, token: signSessionJwt(session) }
}

function dbPlayerToSessionInit(
export function dbPlayerToSessionInit(
dbPlayer: PlayerRecord,
overrides: Partial<SessionInit> = {},
): SessionInit {
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/features/auth/ensure-session.ts
Original file line number Diff line number Diff line change
@@ -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<PlayerSession> {
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))
}
11 changes: 4 additions & 7 deletions apps/server/src/features/matchmaking/matchmaking.route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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[] }
Expand Down
105 changes: 105 additions & 0 deletions apps/server/src/tests/routes/session-not-found.repro.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})