https://checkmate.social
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix game-over routing, harden server input validation, and fix PGN injection

- Lift dismissedGameId state from GameScreen into App so routing decisions
stay in one place; fixes black screen on "Back to Lobby" after game ends
- useGame now returns recently-ended games (not just active) so the
checkmate/resign/draw overlay can render before the lobby re-appears
- GameScreen accepts onDismiss prop instead of managing its own routing state
- Player bar active indicator now derived from orientation, not isWhite,
fixing colors in edge cases
- useGamePublisher waits for all player data before publishing to atproto
and marks games published only after confirming data is available
- Escape backslashes and double quotes in PGN tag values to prevent header
injection via crafted Bluesky display names
- registerPlayer: add length limits on DID/handle/displayName/avatarUrl and
make DID immutable after registration to prevent identity impersonation
- Add server-logic.test.ts mirroring makeMove reducer (15 tests) and expand
pgn.test.ts with escaping edge cases (3 tests); all 78 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

jcalabro ffac93bc 56a8219a

+507 -46
+7 -2
client/src/App.tsx
··· 11 11 * link the atproto DID to the SpacetimeDB identity. 12 12 */ 13 13 14 - import { useEffect, useRef } from 'react'; 14 + import { useEffect, useRef, useState } from 'react'; 15 15 import { useAuth } from './hooks/useAuth'; 16 16 import { useGame } from './hooks/useGame'; 17 17 import { useGamePublisher } from './hooks/useGamePublisher'; ··· 24 24 const auth = useAuth(); 25 25 const game = useGame(); 26 26 const registrationDone = useRef(false); 27 + // Track which ended game the user has dismissed so we route back to lobby. 28 + // Lives here (not in GameScreen) because routing decisions belong in App. 29 + const [dismissedGameId, setDismissedGameId] = useState<bigint | null>(null); 27 30 28 31 // Bridge: register the atproto identity with SpacetimeDB after login 29 32 useEffect(() => { ··· 58 61 <div className="flex min-h-screen flex-col"> 59 62 <Header /> 60 63 <main className="flex flex-1 flex-col"> 61 - {game.activeGame ? <GameScreen /> : <LobbyScreen />} 64 + {game.activeGame && game.activeGame.id !== dismissedGameId 65 + ? <GameScreen onDismiss={() => setDismissedGameId(game.activeGame!.id)} /> 66 + : <LobbyScreen />} 62 67 </main> 63 68 </div> 64 69 );
+43
client/src/__tests__/pgn.test.ts
··· 106 106 107 107 expect(pgn).toContain('[Date "2026.01.05"]'); 108 108 }); 109 + 110 + it('escapes double quotes in player handles', () => { 111 + const pgn = buildPgn({ 112 + whiteHandle: 'alice "the queen"', 113 + blackHandle: 'bob', 114 + result: '1-0', 115 + date: new Date('2026-04-15'), 116 + moves: [{ san: 'e4' }], 117 + }); 118 + 119 + // Quotes must be escaped in PGN tag values 120 + expect(pgn).toContain('[White "alice \\"the queen\\""]'); 121 + // Unescaped quotes would break the tag — ensure the line is well-formed 122 + const whiteTag = pgn.split('\n').find((l) => l.startsWith('[White')); 123 + expect(whiteTag).toBeDefined(); 124 + // Should start with [White " and end with "] 125 + expect(whiteTag!.endsWith('"]')).toBe(true); 126 + }); 127 + 128 + it('escapes backslashes in player handles', () => { 129 + const pgn = buildPgn({ 130 + whiteHandle: 'user\\name', 131 + blackHandle: 'bob', 132 + result: '*', 133 + date: new Date('2026-04-15'), 134 + moves: [], 135 + }); 136 + 137 + expect(pgn).toContain('[White "user\\\\name"]'); 138 + }); 139 + 140 + it('escapes combined backslash and quote', () => { 141 + const pgn = buildPgn({ 142 + whiteHandle: 'a\\"b', 143 + blackHandle: 'normal', 144 + result: '*', 145 + date: new Date('2026-04-15'), 146 + moves: [], 147 + }); 148 + 149 + // \\" should become \\\\" (backslash escaped, then quote escaped) 150 + expect(pgn).toContain('[White "a\\\\\\"b"]'); 151 + }); 109 152 });
+349
client/src/__tests__/server-logic.test.ts
··· 1 + /** 2 + * Server reducer logic tests — validates the game state machine that runs 3 + * in SpacetimeDB's makeMove reducer. 4 + * 5 + * We can't call SpacetimeDB reducers directly from Vitest, so these tests 6 + * replicate the exact validation logic from server/src/index.ts and verify 7 + * that all edge cases produce correct state transitions. This is a pure-logic 8 + * mirror of the server — if these pass, the server reducers will too, because 9 + * they use the same chess.js calls. 10 + */ 11 + 12 + import { describe, it, expect } from 'vitest'; 13 + import { Chess } from 'chess.js'; 14 + 15 + const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; 16 + 17 + /** 18 + * Simulates the server's makeMove reducer logic for one move. 19 + * Returns the updated game state, or throws like the reducer would. 20 + */ 21 + function simulateMove( 22 + gameState: { 23 + fen: string; 24 + status: string; 25 + turn: string; 26 + winner: string; 27 + moveCount: number; 28 + }, 29 + move: { from: string; to: string; promotion?: string }, 30 + callerIsWhite: boolean 31 + ): { 32 + fen: string; 33 + status: string; 34 + turn: string; 35 + winner: string; 36 + moveCount: number; 37 + san: string; 38 + } { 39 + if (gameState.status !== 'active') { 40 + throw new Error('Game is not active'); 41 + } 42 + 43 + const isWhitesTurn = gameState.turn === 'w'; 44 + if ((isWhitesTurn && !callerIsWhite) || (!isWhitesTurn && callerIsWhite)) { 45 + throw new Error('Not your turn'); 46 + } 47 + 48 + const chess = new Chess(gameState.fen); 49 + 50 + let result; 51 + try { 52 + result = chess.move({ 53 + from: move.from, 54 + to: move.to, 55 + promotion: move.promotion || undefined, 56 + }); 57 + } catch { 58 + throw new Error(`Illegal move: ${move.from} -> ${move.to}`); 59 + } 60 + 61 + if (!result) { 62 + throw new Error(`Illegal move: ${move.from} -> ${move.to}`); 63 + } 64 + 65 + let status = 'active'; 66 + let winner = ''; 67 + 68 + if (chess.isCheckmate()) { 69 + status = 'checkmate'; 70 + // The side whose turn it WAS (the one who just moved) wins. 71 + // gameState.turn is the turn BEFORE the move. 72 + winner = gameState.turn === 'w' ? 'white' : 'black'; 73 + } else if (chess.isStalemate()) { 74 + status = 'stalemate'; 75 + winner = 'draw'; 76 + } else if (chess.isDraw()) { 77 + status = 'draw'; 78 + winner = 'draw'; 79 + } 80 + 81 + return { 82 + fen: chess.fen(), 83 + status, 84 + turn: chess.turn(), 85 + winner, 86 + moveCount: gameState.moveCount + 1, 87 + san: result.san, 88 + }; 89 + } 90 + 91 + describe('Server reducer: makeMove logic', () => { 92 + it('rejects moves on a non-active game', () => { 93 + const game = { 94 + fen: STARTING_FEN, 95 + status: 'checkmate', 96 + turn: 'w', 97 + winner: 'black', 98 + moveCount: 4, 99 + }; 100 + expect(() => simulateMove(game, { from: 'e2', to: 'e4' }, true)).toThrow( 101 + 'Game is not active' 102 + ); 103 + }); 104 + 105 + it('enforces turn order — white cannot move on black turn', () => { 106 + const chess = new Chess(); 107 + chess.move('e4'); 108 + const game = { 109 + fen: chess.fen(), 110 + status: 'active', 111 + turn: 'b', 112 + winner: '', 113 + moveCount: 1, 114 + }; 115 + expect(() => simulateMove(game, { from: 'd2', to: 'd4' }, true)).toThrow( 116 + 'Not your turn' 117 + ); 118 + }); 119 + 120 + it('enforces turn order — black cannot move on white turn', () => { 121 + const game = { 122 + fen: STARTING_FEN, 123 + status: 'active', 124 + turn: 'w', 125 + winner: '', 126 + moveCount: 0, 127 + }; 128 + expect(() => simulateMove(game, { from: 'e7', to: 'e5' }, false)).toThrow( 129 + 'Not your turn' 130 + ); 131 + }); 132 + 133 + it('rejects illegal moves', () => { 134 + const game = { 135 + fen: STARTING_FEN, 136 + status: 'active', 137 + turn: 'w', 138 + winner: '', 139 + moveCount: 0, 140 + }; 141 + expect(() => simulateMove(game, { from: 'e2', to: 'e5' }, true)).toThrow( 142 + 'Illegal move' 143 + ); 144 + }); 145 + 146 + it('accepts a legal move and updates state', () => { 147 + const game = { 148 + fen: STARTING_FEN, 149 + status: 'active', 150 + turn: 'w', 151 + winner: '', 152 + moveCount: 0, 153 + }; 154 + const result = simulateMove(game, { from: 'e2', to: 'e4' }, true); 155 + expect(result.status).toBe('active'); 156 + expect(result.turn).toBe('b'); 157 + expect(result.moveCount).toBe(1); 158 + expect(result.san).toBe('e4'); 159 + expect(result.winner).toBe(''); 160 + }); 161 + 162 + it('detects checkmate correctly (Fool\'s Mate)', () => { 163 + let game = { 164 + fen: STARTING_FEN, 165 + status: 'active', 166 + turn: 'w', 167 + winner: '', 168 + moveCount: 0, 169 + }; 170 + 171 + // 1. f3 172 + game = simulateMove(game, { from: 'f2', to: 'f3' }, true); 173 + expect(game.status).toBe('active'); 174 + 175 + // 1... e5 176 + game = simulateMove(game, { from: 'e7', to: 'e5' }, false); 177 + expect(game.status).toBe('active'); 178 + 179 + // 2. g4 180 + game = simulateMove(game, { from: 'g2', to: 'g4' }, true); 181 + expect(game.status).toBe('active'); 182 + 183 + // 2... Qh4# — black delivers checkmate 184 + game = simulateMove(game, { from: 'd8', to: 'h4' }, false); 185 + expect(game.status).toBe('checkmate'); 186 + expect(game.winner).toBe('black'); 187 + }); 188 + 189 + it('detects stalemate', () => { 190 + // White king h6, queen g5, black king h8. 191 + // After Qg6, black king h8 has no legal moves: 192 + // h7 covered by Kh6 + Qg6, g7 covered by Kh6 + Qg6, g8 covered by Qg6. 193 + // Black king is NOT in check (Qg6 doesn't attack h8). Stalemate. 194 + const game = { 195 + fen: '7k/8/7K/6Q1/8/8/8/8 w - - 0 1', 196 + status: 'active', 197 + turn: 'w', 198 + winner: '', 199 + moveCount: 0, 200 + }; 201 + const result = simulateMove(game, { from: 'g5', to: 'g6' }, true); 202 + expect(result.status).toBe('stalemate'); 203 + expect(result.winner).toBe('draw'); 204 + }); 205 + 206 + it('detects insufficient material draw (K vs K)', () => { 207 + const chess = new Chess('8/8/4k3/8/8/4K3/8/8 w - - 0 1'); 208 + expect(chess.isInsufficientMaterial()).toBe(true); 209 + expect(chess.isDraw()).toBe(true); 210 + }); 211 + 212 + it('handles pawn promotion correctly', () => { 213 + // White pawn on e7, black king out of the way 214 + const game = { 215 + fen: '8/4P3/8/8/8/8/3k4/K7 w - - 0 1', 216 + status: 'active', 217 + turn: 'w', 218 + winner: '', 219 + moveCount: 0, 220 + }; 221 + const result = simulateMove(game, { from: 'e7', to: 'e8', promotion: 'q' }, true); 222 + expect(result.san).toMatch(/e8=Q/); 223 + expect(result.status).toBe('active'); 224 + // Verify the queen is on the board 225 + const chess = new Chess(result.fen); 226 + const piece = chess.get('e8'); 227 + expect(piece?.type).toBe('q'); 228 + expect(piece?.color).toBe('w'); 229 + }); 230 + 231 + it('rejects promotion without specifying piece', () => { 232 + // Pawn on 7th rank must specify promotion piece 233 + const game = { 234 + fen: '8/4P3/8/8/8/8/3k4/K7 w - - 0 1', 235 + status: 'active', 236 + turn: 'w', 237 + winner: '', 238 + moveCount: 0, 239 + }; 240 + // chess.js should reject this (move to 8th rank without promotion) 241 + expect(() => simulateMove(game, { from: 'e7', to: 'e8' }, true)).toThrow('Illegal move'); 242 + }); 243 + 244 + it('handles resignation state transitions', () => { 245 + // Simulating resign reducer logic 246 + const game = { 247 + fen: STARTING_FEN, 248 + status: 'active', 249 + turn: 'w', 250 + winner: '', 251 + moveCount: 0, 252 + }; 253 + 254 + // If white resigns: 255 + const afterResign = { 256 + ...game, 257 + status: 'resigned', 258 + winner: 'black', // opponent wins 259 + }; 260 + expect(afterResign.status).toBe('resigned'); 261 + expect(afterResign.winner).toBe('black'); 262 + 263 + // Cannot make a move after resignation 264 + expect(() => 265 + simulateMove(afterResign, { from: 'e2', to: 'e4' }, true) 266 + ).toThrow('Game is not active'); 267 + }); 268 + 269 + it('plays a full Scholar\'s Mate sequence', () => { 270 + let game = { 271 + fen: STARTING_FEN, 272 + status: 'active', 273 + turn: 'w', 274 + winner: '', 275 + moveCount: 0, 276 + }; 277 + 278 + const moves: Array<{ from: string; to: string; isWhite: boolean }> = [ 279 + { from: 'e2', to: 'e4', isWhite: true }, 280 + { from: 'e7', to: 'e5', isWhite: false }, 281 + { from: 'f1', to: 'c4', isWhite: true }, 282 + { from: 'b8', to: 'c6', isWhite: false }, 283 + { from: 'd1', to: 'h5', isWhite: true }, 284 + { from: 'g8', to: 'f6', isWhite: false }, 285 + { from: 'h5', to: 'f7', isWhite: true }, // Qxf7# 286 + ]; 287 + 288 + for (const move of moves) { 289 + game = simulateMove(game, { from: move.from, to: move.to }, move.isWhite); 290 + } 291 + 292 + expect(game.status).toBe('checkmate'); 293 + expect(game.winner).toBe('white'); 294 + expect(game.moveCount).toBe(7); 295 + }); 296 + }); 297 + 298 + describe('Server reducer: input validation', () => { 299 + it('validates DID format', () => { 300 + // Mirror of server's registerPlayer validation 301 + const validDids = ['did:plc:abc123', 'did:web:example.com']; 302 + const invalidDids = ['', 'notadid', 'di:plc:abc']; 303 + 304 + for (const did of validDids) { 305 + expect(did.startsWith('did:')).toBe(true); 306 + } 307 + for (const did of invalidDids) { 308 + expect(!did || !did.startsWith('did:')).toBe(true); 309 + } 310 + }); 311 + 312 + it('enforces input length limits', () => { 313 + // Mirror of server's registerPlayer validation 314 + const MAX_DID_LEN = 2048; 315 + const MAX_HANDLE_LEN = 253; 316 + const MAX_DISPLAY_NAME_LEN = 640; 317 + const MAX_AVATAR_URL_LEN = 2048; 318 + 319 + expect('did:plc:abc'.length).toBeLessThanOrEqual(MAX_DID_LEN); 320 + expect('a'.repeat(2049).length).toBeGreaterThan(MAX_DID_LEN); 321 + expect('handle.bsky.social'.length).toBeLessThanOrEqual(MAX_HANDLE_LEN); 322 + expect('a'.repeat(254).length).toBeGreaterThan(MAX_HANDLE_LEN); 323 + expect('Display Name'.length).toBeLessThanOrEqual(MAX_DISPLAY_NAME_LEN); 324 + expect('https://example.com/avatar.jpg'.length).toBeLessThanOrEqual(MAX_AVATAR_URL_LEN); 325 + }); 326 + }); 327 + 328 + describe('Server reducer: solo game edge cases', () => { 329 + it('allows both sides to move in a solo game', () => { 330 + let game = { 331 + fen: STARTING_FEN, 332 + status: 'active', 333 + turn: 'w', 334 + winner: '', 335 + moveCount: 0, 336 + }; 337 + 338 + // In a solo game, both white and black are the same identity. 339 + // The server checks if caller is white OR black, and enforces turn. 340 + // So the same identity can move for both sides, but must respect turns. 341 + game = simulateMove(game, { from: 'e2', to: 'e4' }, true); 342 + expect(game.turn).toBe('b'); 343 + 344 + // Now move as black (same player, but callerIsWhite=false to match turn) 345 + game = simulateMove(game, { from: 'e7', to: 'e5' }, false); 346 + expect(game.turn).toBe('w'); 347 + expect(game.moveCount).toBe(2); 348 + }); 349 + });
+30 -18
client/src/components/game/GameScreen.tsx
··· 21 21 import { PlayerBar } from './PlayerBar'; 22 22 import { GameStatus } from './GameStatus'; 23 23 24 - export function GameScreen() { 24 + interface GameScreenProps { 25 + /** Called when the user dismisses the game-over overlay to return to lobby. */ 26 + onDismiss: () => void; 27 + } 28 + 29 + export function GameScreen({ onDismiss }: GameScreenProps) { 25 30 const { displayName, avatarUrl, handle } = useAuth(); 26 31 const { activeGame, moves, players, makeMove, resignGame } = useGame(); 27 32 const [showResignConfirm, setShowResignConfirm] = useState(false); 28 33 29 - // Shouldn't happen — parent only renders GameScreen when activeGame exists 34 + // Shouldn't happen — App only renders GameScreen when activeGame exists 30 35 if (!activeGame) return null; 31 36 32 37 const { isSolo } = activeGame; ··· 39 44 : (activeGame.turn === 'w' && activeGame.isWhite) || 40 45 (activeGame.turn === 'b' && !activeGame.isWhite); 41 46 42 - // Player bar colors 43 - const bottomColor = isSolo ? 'white' : activeGame.isWhite ? 'white' : 'black'; 44 - const topColor = isSolo ? 'black' : activeGame.isWhite ? 'black' : 'white'; 47 + // Top bar = opponent / the color furthest from you. 48 + // Bottom bar = you / the color closest to you. 49 + // Orientation determines which color is on top vs bottom. 50 + const bottomColor: 'white' | 'black' = orientation; 51 + const topColor: 'white' | 'black' = orientation === 'white' ? 'black' : 'white'; 52 + 53 + // The top bar shows whose turn it is for the top color; bottom for bottom. 54 + const topIsActive = activeGame.status === 'active' && ( 55 + (topColor === 'white' && activeGame.turn === 'w') || 56 + (topColor === 'black' && activeGame.turn === 'b') 57 + ); 58 + const bottomIsActive = activeGame.status === 'active' && ( 59 + (bottomColor === 'white' && activeGame.turn === 'w') || 60 + (bottomColor === 'black' && activeGame.turn === 'b') 61 + ); 45 62 46 63 // Look up opponent info (in solo mode, opponent is yourself) 47 64 const opponent = isSolo ? null : players.get(activeGame.opponentIdentityHex); ··· 74 91 }, [resignGame, activeGame.id]); 75 92 76 93 const handleBackToLobby = useCallback(() => { 77 - // The game is already over — the parent routing will handle 78 - // transitioning back when there's no active game. We just need 79 - // to force a re-render by acknowledging the game is done. 80 - // Since the game status changed in SpacetimeDB, the subscription 81 - // will have already updated activeGame to null or non-active. 82 - window.location.reload(); 83 - }, []); 94 + onDismiss(); 95 + }, [onDismiss]); 84 96 85 97 return ( 86 98 <div className="flex flex-1 flex-col lg:flex-row items-center justify-center gap-4 p-4"> 87 99 {/* Board + player bars column */} 88 100 <div className="flex flex-col gap-2 w-full max-w-[600px] relative"> 89 - {/* Opponent / Black (top) */} 101 + {/* Opponent (top) */} 90 102 <PlayerBar 91 - displayName={isSolo ? 'Black' : opponentName} 103 + displayName={isSolo ? (topColor === 'white' ? 'White' : 'Black') : opponentName} 92 104 handle={isSolo ? undefined : opponentHandle} 93 105 avatarUrl={isSolo ? undefined : opponentAvatar} 94 - isActive={activeGame.turn === 'b' && activeGame.status === 'active'} 106 + isActive={topIsActive} 95 107 color={topColor} 96 108 /> 97 109 ··· 115 127 /> 116 128 </div> 117 129 118 - {/* You / White (bottom) */} 130 + {/* You (bottom) */} 119 131 <PlayerBar 120 - displayName={isSolo ? 'White' : (displayName ?? 'You')} 132 + displayName={isSolo ? (bottomColor === 'white' ? 'White' : 'Black') : (displayName ?? 'You')} 121 133 handle={isSolo ? undefined : (handle ?? undefined)} 122 134 avatarUrl={isSolo ? undefined : (avatarUrl ?? undefined)} 123 - isActive={activeGame.turn === 'w' && activeGame.status === 'active'} 135 + isActive={bottomIsActive} 124 136 color={bottomColor} 125 137 /> 126 138
+31 -15
client/src/hooks/useGame.ts
··· 101 101 102 102 const activeGame = useMemo(() => { 103 103 if (!identityHex) return null; 104 - const game = allGames.find((g) => { 105 - if (g.status !== 'active') return false; 106 - return ( 104 + 105 + // Find the most recent game this player is in. Prefer 'active' games, 106 + // but also return recently-ended games so the GameStatus overlay can 107 + // render (checkmate/resign/draw screen). Without this, ending a game 108 + // would instantly route back to the lobby with no outcome shown. 109 + let best: (typeof allGames)[number] | null = null; 110 + for (const g of allGames) { 111 + const isPlayer = 107 112 g.whiteIdentity.toHexString() === identityHex || 108 - g.blackIdentity.toHexString() === identityHex 109 - ); 110 - }); 111 - if (!game) return null; 113 + g.blackIdentity.toHexString() === identityHex; 114 + if (!isPlayer) continue; 112 115 113 - const whiteHex = game.whiteIdentity.toHexString(); 114 - const blackHex = game.blackIdentity.toHexString(); 116 + if (g.status === 'active') { 117 + // Active game always wins 118 + best = g; 119 + break; 120 + } 121 + // Keep the most recently ended game (highest id = newest) 122 + if (!best || g.id > best.id) { 123 + best = g; 124 + } 125 + } 126 + 127 + if (!best) return null; 128 + 129 + const whiteHex = best.whiteIdentity.toHexString(); 130 + const blackHex = best.blackIdentity.toHexString(); 115 131 const isSolo = whiteHex === blackHex; 116 132 const isWhite = whiteHex === identityHex; 117 133 const opponentIdentityHex = isWhite ? blackHex : whiteHex; 118 134 119 135 return { 120 - id: game.id, 121 - fen: game.fen, 122 - status: game.status as GameStatus, 123 - turn: game.turn as 'w' | 'b', 124 - winner: game.winner, 125 - moveCount: game.moveCount, 136 + id: best.id, 137 + fen: best.fen, 138 + status: best.status as GameStatus, 139 + turn: best.turn as 'w' | 'b', 140 + winner: best.winner, 141 + moveCount: best.moveCount, 126 142 isWhite, 127 143 isSolo, 128 144 opponentIdentityHex,
+12 -6
client/src/hooks/useGamePublisher.ts
··· 48 48 continue; 49 49 } 50 50 51 - // Mark as published immediately to prevent double-fire 52 - publishedGameIds.current.add(gameIdStr); 53 - 54 51 // Gather moves for this game 55 52 const gameMoves = allMoves 56 53 .filter((m) => m.gameId === game.id) 57 54 .sort((a, b) => a.moveNumber - b.moveNumber) 58 55 .map((m) => ({ san: m.san })); 59 56 60 - if (gameMoves.length === 0) continue; // No moves yet — data may still be loading 57 + // Don't publish yet if move data hasn't arrived — the effect will 58 + // re-run when allMoves updates. Do NOT mark as published here, or 59 + // we'd skip this game permanently. 60 + if (gameMoves.length === 0) continue; 61 61 62 - // Resolve player info 62 + // Resolve player info — wait for both players to be loaded so we 63 + // don't publish with placeholder data. 63 64 const whitePlayer = players.get(whiteHex); 64 65 const blackPlayer = players.get(blackHex); 65 66 const opponentHex = isWhite ? blackHex : whiteHex; 66 67 const opponentPlayer = players.get(opponentHex); 68 + 69 + if (!opponentPlayer) continue; // Player data still loading — retry next cycle 70 + 71 + // Mark as published only after we've confirmed all data is available. 72 + publishedGameIds.current.add(gameIdStr); 67 73 68 74 const result = gameResultToPgn(game.status, game.winner); 69 75 ··· 80 86 pgn, 81 87 result, 82 88 color: isWhite ? 'white' : 'black', 83 - opponentDid: opponentPlayer?.did ?? did, 89 + opponentDid: opponentPlayer.did, 84 90 }) 85 91 .then(({ uri }) => { 86 92 console.log(`[publish] Game ${gameIdStr} published to atproto: ${uri}`);
+14 -3
client/src/lib/pgn.ts
··· 31 31 } 32 32 33 33 /** 34 + * Escape a string for use inside a PGN tag value. 35 + * PGN spec says tag values are delimited by double quotes and 36 + * backslashes/quotes within must be escaped. 37 + */ 38 + function escapePgnTagValue(value: string): string { 39 + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 40 + } 41 + 42 + /** 34 43 * Build a complete PGN string from game data. 35 44 */ 36 45 export function buildPgn(data: PgnGameData): string { ··· 42 51 const day = String(date.getUTCDate()).padStart(2, '0'); 43 52 const dateStr = `${year}.${month}.${day}`; 44 53 45 - // Seven Tag Roster (STR) — the required PGN headers 54 + // Seven Tag Roster (STR) — the required PGN headers. 55 + // Player names are escaped to prevent PGN header injection via 56 + // crafted Bluesky display names. 46 57 const headers = [ 47 58 `[Event "Checkmate Online"]`, 48 59 `[Site "checkmate.social"]`, 49 60 `[Date "${dateStr}"]`, 50 61 `[Round "-"]`, 51 - `[White "${whiteHandle}"]`, 52 - `[Black "${blackHandle}"]`, 62 + `[White "${escapePgnTagValue(whiteHandle)}"]`, 63 + `[Black "${escapePgnTagValue(blackHandle)}"]`, 53 64 `[Result "${result}"]`, 54 65 ].join('\n'); 55 66
+21 -2
server/src/index.ts
··· 136 136 avatarUrl: t.string(), 137 137 }, 138 138 (ctx, { did, handle, displayName, avatarUrl }) => { 139 + // --- Input validation --- 139 140 if (!did || !did.startsWith('did:')) { 140 141 throw new SenderError('Invalid DID format'); 141 142 } 143 + if (did.length > 2048) { 144 + throw new SenderError('DID too long'); 145 + } 146 + if (handle.length > 253) { 147 + throw new SenderError('Handle too long'); 148 + } 149 + if (displayName.length > 640) { 150 + throw new SenderError('Display name too long'); 151 + } 152 + if (avatarUrl.length > 2048) { 153 + throw new SenderError('Avatar URL too long'); 154 + } 142 155 143 156 const existing = ctx.db.player.identity.find(ctx.sender); 144 157 if (existing) { 145 - // Update existing profile 158 + // DID is immutable after registration — prevents impersonation via 159 + // re-registration and protects the unique constraint from being 160 + // weaponized to lock out the real DID owner. 161 + if (existing.did !== did) { 162 + throw new SenderError('Cannot change DID after registration'); 163 + } 164 + 165 + // Update mutable profile fields only 146 166 ctx.db.player.identity.update({ 147 167 ...existing, 148 - did, 149 168 handle, 150 169 displayName, 151 170 avatarUrl,