Compare commits

...

3 Commits

Author SHA1 Message Date
LQ63 9dc0b4a7df fix(ncs-122): send WS token via first-message auth instead of query param
Remove token from WebSocket URL query parameters in ChallengeWebSocketService
and GameApiService. Instead, send {"type":"auth","token":"..."} as the first
text frame after the connection opens, matching the new backend auth protocol.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 09:35:02 +02:00
TeamCity a3e51bade5 ci: bump version to v0.3.0 2026-06-10 20:41:14 +00:00
lq64 48959daae3 feat: bots (#9)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-06-10 22:38:06 +02:00
9 changed files with 140 additions and 135 deletions
+5
View File
@@ -56,3 +56,8 @@
### Bug Fixes ### Bug Fixes
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) * route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
### Features
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
@@ -192,12 +192,12 @@
<span class="dialog-brand">Join with a bot</span> <span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button> <button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div> </div>
<p class="join-hint">Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</p> <p class="join-hint">Select an official (engine-backed) bot to enter this tournament. These are the bots that actually play their moves.</p>
@if (botsLoading) { @if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div> <div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) { } @else if (userBots.length === 0) {
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p> <p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else { } @else {
<div class="bot-pick-list"> <div class="bot-pick-list">
@for (bot of userBots; track bot.id) { @for (bot of userBots; track bot.id) {
@@ -158,7 +158,7 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
this.botService.list() this.botService.listOfficial()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: bots => { this.userBots = bots; this.botsLoading = false; }, next: bots => { this.userBots = bots; this.botsLoading = false; },
@@ -176,30 +176,22 @@ export class TournamentsComponent implements OnInit {
if (!this.joinDialogTournamentId || this.joiningBotId) return; if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id; this.joiningBotId = bot.id;
this.joinError = null; this.joinError = null;
this.botService.rotateToken(bot.id).subscribe({ this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
next: token => { next: () => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => {
this.joiningBotId = null;
const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
});
},
error: () => {
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = 'Failed to get bot token.'; const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
} }
}); });
} }
+5
View File
@@ -7,11 +7,16 @@ import { Bot, BotWithToken } from '../models/bot.models';
export class BotService { export class BotService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots'; private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> { list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base); return this.http.get<Bot[]>(this.base);
} }
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> { create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name }); return this.http.post<BotWithToken>(this.base, { name });
} }
+96 -91
View File
@@ -6,110 +6,115 @@ import { ChallengeService } from './challenge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService { export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService); private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router); private readonly router = inject(Router);
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectAttempts = 0; private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5; private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000; private readonly reconnectDelay = 3000;
private intentionalClose = false; private intentionalClose = false;
connect(): void { connect(): void {
if (this.ws) return; if (this.ws) return;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`; const url = `${environment.userWsBaseUrl}/api/user/ws`;
try { try {
this.intentionalClose = false; this.intentionalClose = false;
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
if (this.ws) { this.ws?.send(JSON.stringify({ type: 'auth', token }));
this.ws.close(); };
this.ws = null;
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
} }
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
} }
private handleMessage(data: string): void { switch (message['type']) {
let message: Record<string, unknown>; case 'CONNECTED':
try { break;
message = JSON.parse(data) as Record<string, unknown>;
} catch { case 'challengeCreated': {
return; const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
} }
break;
}
switch (message['type']) { case 'challengeAccepted': {
case 'CONNECTED': const challengeId = message['challengeId'] as string | undefined;
break; const gameId = message['gameId'] as string | undefined;
if (challengeId) {
case 'challengeCreated': { this.challengeEventService.removeChallenge(challengeId);
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
} }
} if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
private attemptReconnect(): void { case 'challengeDeclined':
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; case 'challengeExpired':
this.reconnectAttempts++; case 'challengeCancelled': {
setTimeout(() => { this.connect(); }, this.reconnectDelay); const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
} }
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
} }
+9 -10
View File
@@ -7,7 +7,7 @@ import {
GameState, GameState,
GameStreamEvent, GameStreamEvent,
LegalMovesResponse, LegalMovesResponse,
PlayerInfo PlayerInfo,
} from '../models/game.models'; } from '../models/game.models';
import { StreamHandlerService } from './stream-handler.service'; import { StreamHandlerService } from './stream-handler.service';
@@ -28,11 +28,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black'; const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = { const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`, id: `player-${Date.now()}`,
displayName: 'You' displayName: 'You',
}; };
const botInfo: PlayerInfo = { const botInfo: PlayerInfo = {
id: `bot-${difficulty}`, id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})` displayName: `Bot (${difficulty})`,
}; };
const payload = const payload =
@@ -56,7 +56,9 @@ export class GameApiService {
if (square) { if (square) {
params = params.set('square', square); params = params.set('square', square);
} }
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
params,
});
} }
importFen(fen: string): Observable<GameFull> { importFen(fen: string): Observable<GameFull> {
@@ -85,11 +87,8 @@ export class GameApiService {
} }
streamGame(gameId: string): Observable<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
const token = localStorage.getItem('token'); const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; const token = localStorage.getItem('token') ?? '';
if (token) { return this.streamHandler.createGameStream(wsUrl, gameId, token);
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
} }
} }
+3 -2
View File
@@ -6,7 +6,7 @@ const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StreamHandlerService { export class StreamHandlerService {
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> { createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => { return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
let connected = false; let connected = false;
@@ -14,7 +14,7 @@ export class StreamHandlerService {
const emitErrorEvent = (message: string): void => { const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = { const errorEvent: ErrorEvent = {
type: 'error', type: 'error',
error: { code: 'STREAM_ERROR', message } error: { code: 'STREAM_ERROR', message },
}; };
observer.next(errorEvent); observer.next(errorEvent);
}; };
@@ -36,6 +36,7 @@ export class StreamHandlerService {
connected = true; connected = true;
clearTimeout(connectionTimeoutId); clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`); console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
}; };
ws.onmessage = (message) => { ws.onmessage = (message) => {
+2 -4
View File
@@ -40,10 +40,8 @@ export class TournamentService {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null); return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
} }
joinWithBotToken(id: string, botToken: string): Observable<void> { join(id: string, botId: string, botName: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, null, { return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
});
} }
roundPairings(id: string, round: number): Observable<RoundPairings> { roundPairings(id: string, round: number): Observable<RoundPairings> {
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=2 MINOR=3
PATCH=8 PATCH=0