Balatro-Multiplayer / Balatro-Multiplayer/BalatroMultiplayerAPI-Server
Matchmaking returns "Session not found" (401) for a valid JWT after a transient MQTT disconnect, with no recovery path
- Dominant language
- TypeScript
- Stars
- 13
- Forks
- 20
- Avg merge
- 3d 7h
- Merged PRs (30d)
- 7
Description
# Matchmaking returns "Session not found" (401) for a player with a valid JWT, with no recovery path
## Summary
A player who is correctly authenticated (valid JWT) can be permanently locked out of matchmaking with `Session not found` (401) after a single transient MQTT disconnect. Retrying does not help, because nothing recreates the in-memory session and the still-valid JWT means the client never re-authenticates. The only workaround is a full client restart.
## Root cause
The in-memory session and the stateless JWT have decoupled lifetimes, and the matchmaking route is the only flow that requires the session strictly instead of self-healing.
1. **Sessions are in-memory only.** `src/state/index.ts` — `export const sessions = new Map()`. No persistence, no rehydration on startup.
2. **JWT auth never checks for a session.** `src/middleware/authenticate.ts` only runs `verifyJwt(token)` and sets `req.player`. A valid token passes even when the session is gone.
3. **A non-lobby disconnect reaps the session immediately.** `src/features/emqx/emqx.route.ts`:
```js
async function releasePlayerLobbyOrSession(clientid) {
const session = getSession(clientid)
if (!session) return
leaveAllQueues(clientid)
if (session.lobbyCode) {
await startGracePeriod(clientid) // lobby members get a grace period
} else {
removeSession(clientid) // everyone else is removed on the spot
}
}
```
The MQTT `clientid` is the `playerId` (the client sends `player_id` as the MQTT client id), so `getSession(clientid)` / `removeSession(clientid)` resolve the real session. A player who is matchmaking — and therefore not yet in a lobby — has their session deleted the instant EMQX reports `client.disconnected`.
4. **Matchmaking requires the session strictly.** `src/features/matchmaking/matchmaking.route.ts`:
```js
const session = getSession(req.player.playerId)
if (!session) throw new AppError('Session not found', 401)
```
By contrast, ~5 other endpoints call `ensureSession()` (`src/features/auth/auth.service.ts`), which rebuilds the session from the DB when it's missing. Matchmaking is the asymmetric one that does not self-heal.
## Reproduction
1. Authenticate (Steam) — session created, MQTT connected.
2. Drop the MQTT connection briefly (ordinary network blip). The client default is `reconnect = false`, so it does not auto-reconnect.
3. EMQX fires `client.disconnected`; since the player isn't in a lobby, the server calls `removeSession` immediately.
4. Click matchmake → `POST /api/matchmaking/queue` with the still-valid JWT → `getSession` returns nothing → **`Session not found` (401)**.
5. Retrying never recovers: the JWT is still valid, so the client doesn't re-auth, and nothing else recreates the session.
## Impact
A single transient disconnect strands an authenticated player. From the user's side it looks like matchmaking is simply broken; the only fix they have is to fully quit and relaunch the game (which forces a fresh `/auth` and recreates the session).
## Suggested fixes (in order of smallest blast radius)
1. **Make matchmaking self-heal like the other routes** — use `ensureSession(req.player.playerId)` instead of strict `getSession`. A DB-backed (Steam-authed) player would transparently get their session rebuilt instead of a 401. This is the minimal, consistent fix.
2. **Don't immediately reap non-lobby sessions on disconnect** — give them the same grace period lobby members get, so a brief blip doesn't destroy the session.
3. **(Client-side, optional)** Treat a `401 Session not found` as "re-authenticate, then retry" rather than surfacing it as a terminal error.
## Notes / unverified
The mechanism above is confirmed against the code end-to-end (in-memory map, stateless JWT, immediate non-lobby reap, strict matchmaking lookup, `clientid == playerId`). What is *not* independently confirmed is that a given user report was caused by this exact path versus another session-loss trigger (e.g. a server restart/redeploy wiping the in-memory map, or a grace-period expiry). All of those funnel into the same end state — valid JWT, no session, strict 401 — so the fixes apply regardless.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.