-
Notifications
You must be signed in to change notification settings - Fork 56
implement stronger input validation for tip metadata. #292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { Injectable, Logger } from '@nestjs/common'; | ||
|
|
||
| export interface StoredEvent { | ||
| sequenceId: number; | ||
| type: string; | ||
| data: any; | ||
| rooms: string[]; | ||
| timestamp: Date; | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class EventStoreService { | ||
| private readonly logger = new Logger(EventStoreService.name); | ||
| private events: StoredEvent[] = []; | ||
| private nextSequenceId = 1; | ||
| private readonly maxWindowSize = 1000; // Store up to 1000 recent events | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Detect stale cursors instead of silently replaying a partial backlog. Once the 1000-event window rolls over, older events are evicted. If a client reconnects with a Also applies to: 33-35, 44-50 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Store a new event and assign a sequence ID | ||
| */ | ||
| storeEvent(type: string, data: any, rooms: string[]): StoredEvent { | ||
| const event: StoredEvent = { | ||
| sequenceId: this.nextSequenceId++, | ||
| type, | ||
| data, | ||
| rooms, | ||
| timestamp: new Date(), | ||
| }; | ||
|
|
||
| this.events.push(event); | ||
|
|
||
| // Keep only the sliding window of events | ||
| if (this.events.length > this.maxWindowSize) { | ||
| this.events.shift(); | ||
| } | ||
|
|
||
| this.logger.debug(`Stored event ${event.sequenceId} (${type}) for rooms: ${rooms.join(', ')}`); | ||
| return event; | ||
| } | ||
|
|
||
| /** | ||
| * Get missed events for a client after a specific sequence ID | ||
| */ | ||
| getEventsAfter(sequenceId: number, targetRooms: string[]): StoredEvent[] { | ||
| // Only return events that the client should have access to based on their joined rooms | ||
| return this.events.filter( | ||
| (event) => | ||
| event.sequenceId > sequenceId && | ||
| event.rooms.some((room) => targetRooms.includes(room)) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Get the latest sequence ID | ||
| */ | ||
| getLatestSequenceId(): number { | ||
| return this.nextSequenceId - 1; | ||
| } | ||
|
|
||
| /** | ||
| * Clear the event store (useful for testing) | ||
| */ | ||
| clear(): void { | ||
| this.events = []; | ||
| this.nextSequenceId = 1; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,10 +9,14 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@nestjs/websockets'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Server, Socket } from 'socket.io'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Logger } from '@nestjs/common'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { OnEvent } from '@nestjs/event-emitter'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Tip } from '../tips/entities/tip.entity'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { EventStoreService } from './event-store.service'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { TipVerifiedEvent } from '../tips/events/tip-verified.event'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface TipNotificationData { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'tip_received'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sequenceId: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tipId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artistId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -43,15 +47,26 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly logger = new Logger(WebSocketGateway.name); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private connectedClients: Map<string, Socket> = new Map(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleConnection(client: Socket): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| constructor(private readonly eventStore: EventStoreService) {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async handleConnection(client: Socket): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Client connected: ${client.id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.connectedClients.set(client.id, client); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send welcome message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('connected', { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: 'Connected to TipTune WebSocket', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clientId: client.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| latestSequenceId: this.eventStore.getLatestSequenceId(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle initial catch-up if requested | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lastSequenceId = parseInt(client.handshake.query.lastSequenceId as string); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isNaN(lastSequenceId)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Client ${client.id} requested catch-up from sequence: ${lastSequenceId}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Replay missed events will be handled after rooms are joined or based on past rooms if stored | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // For now, we'll allow an explicit 'request_missed_events' message or handle it post-authentication | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleDisconnect(client: Socket): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,13 +77,18 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('join_artist_room') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleJoinArtistRoom( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ConnectedSocket() client: Socket, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @MessageBody() data: { artistId: string }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @MessageBody() data: { artistId: string; lastSequenceId?: number }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const room = `artist_${data.artistId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.join(room); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Client ${client.id} joined room: ${room}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('joined_room', { room, artistId: data.artistId }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Catch up if lastSequenceId provided | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (data.lastSequenceId !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.replayMissedEvents(client, data.lastSequenceId, [room]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('leave_artist_room') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -86,13 +106,18 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('join_track_room') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleJoinTrackRoom( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ConnectedSocket() client: Socket, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @MessageBody() data: { trackId: string }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @MessageBody() data: { trackId: string; lastSequenceId?: number }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const room = `track_${data.trackId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.join(room); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Client ${client.id} joined room: ${room}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('joined_room', { room, trackId: data.trackId }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Catch up if lastSequenceId provided | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (data.lastSequenceId !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.replayMissedEvents(client, data.lastSequenceId, [room]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('leave_track_room') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -107,54 +132,98 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('left_room', { room, trackId: data.trackId }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('ack') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleAck( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ConnectedSocket() client: Socket, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @MessageBody() data: { sequenceId: number }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.debug(`Client ${client.id} acknowledged sequence ID: ${data.sequenceId}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SubscribeMessage('ping') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handlePing(@ConnectedSocket() client: Socket): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('pong', { timestamp: new Date() }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Send tip notification to relevant rooms | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Listen for tip.verified events from TipsService | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @OnEvent('tip.verified') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async handleTipVerifiedEvent(event: TipVerifiedEvent): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Received tip.verified event for tip ${event.tip.id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.sendTipNotification(event.tip); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Send tip notification to relevant rooms and store it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async sendTipNotification(tip: Tip): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const artistRoom = `artist_${tip.artistId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const trackRoom = tip.trackId ? `track_${tip.trackId}` : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rooms = [artistRoom, 'global']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (trackRoom) rooms.push(trackRoom); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payload = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tipId: tip.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artistId: tip.artistId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trackId: tip.trackId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| amount: tip.amount, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| asset: tip.asset, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: tip.message, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| senderAddress: tip.isAnonymous ? undefined : tip.senderAddress, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isAnonymous: tip.isAnonymous, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| createdAt: tip.createdAt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artist: tip.artist, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| track: tip.track, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+167
to
+179
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Send Lines 152-177 of Suggested fix- asset: tip.asset,
+ asset: tip.assetCode ?? tip.asset,🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Store event first to get sequence ID | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const storedEvent = this.eventStore.storeEvent('tip_received', payload, rooms); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const notificationData: TipNotificationData = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'tip_received', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tipId: tip.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artistId: tip.artistId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trackId: tip.trackId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| amount: tip.amount, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| asset: tip.asset, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: tip.message, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| senderAddress: tip.isAnonymous ? undefined : tip.senderAddress, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isAnonymous: tip.isAnonymous, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| createdAt: tip.createdAt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| artist: tip.artist, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| track: tip.track, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sequenceId: storedEvent.sequenceId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: payload, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send to artist room | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const artistRoom = `artist_${tip.artistId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.server.to(artistRoom).emit('tip_notification', notificationData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent tip notification to room: ${artistRoom}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send to track room if track is specified | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (tip.trackId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const trackRoom = `track_${tip.trackId}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (trackRoom) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.server.to(trackRoom).emit('tip_notification', notificationData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent tip notification to room: ${trackRoom}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${trackRoom}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
190
to
198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "websocket.gateway.ts"Repository: OlufunbiIK/tip-tune Length of output: 107 🏁 Script executed: wc -l ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 111 🏁 Script executed: sed -n '150,220p' ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 2678 🏁 Script executed: grep -n "socket.join\|client.join" ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 115 🏁 Script executed: sed -n '75,125p' ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 1651 🏁 Script executed: head -30 ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 879 🏁 Script executed: cat > /tmp/test_socketio.js << 'EOF'
// Testing Socket.io room behavior
// Documenting Socket.io API
// According to Socket.io documentation:
// server.to(room1).to(room2).emit() - emits to the INTERSECTION (rooms that have both)
// To emit to UNION, you need to emit separately or use different approach
console.log("Socket.io room chaining behavior:");
console.log("server.to(room1).emit() - emits to all sockets in room1");
console.log("server.to(room1).to(room2).emit() - emits only to sockets in BOTH room1 AND room2 (intersection)");
console.log("");
console.log("If a socket is in artist_1 and track_5:");
console.log("- Current code: emits to artist_1, then emits to track_5 -> socket receives twice");
console.log("- Suggested fix with chaining: would only emit to sockets in BOTH rooms");
console.log("");
console.log("To solve the duplicate without losing messages:");
console.log("- Option 1: Track socket IDs and emit once per socket");
console.log("- Option 2: Use namespace or user-based approach");
console.log("- Option 3: Emit to union and deduplicate at client");
EOF
cat /tmp/test_socketio.jsRepository: OlufunbiIK/tip-tune Length of output: 1110 🌐 Web query:
💡 Result: The behavior of server.to(room1).to(room2).emit(...) in Socket.IO is a union of the rooms, not an intersection. Every socket that is in at least one of the specified rooms receives the event exactly once, even if it belongs to multiple rooms. This is explicitly stated in the official Socket.IO documentation across multiple versions: - "In that case, a union is performed: every socket that is at least in one of the rooms will get the event once (even if the socket is in two or more rooms)." (Rooms docs v3 and v4) - Emit cheatsheet example: io.to("room1").to(["room2", "room3"]).except("room4").emit("hello"); describes targeting clients "in at least one of the rooms". The to method chains by accumulating room names in the socket's _rooms array, and the broadcast targets the union via the adapter. There is no intersection behavior; except is used for exclusion instead. Citations:
🏁 Script executed: sed -n '188,220p' ./backend/src/websocket/websocket.gateway.tsRepository: OlufunbiIK/tip-tune Length of output: 1415 Broadcast once to the room union. A socket can be in both Suggested fix- this.server.to(artistRoom).emit('tip_notification', notificationData);
- this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
-
- // Send to track room if track is specified
- if (trackRoom) {
- this.server.to(trackRoom).emit('tip_notification', notificationData);
- this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${trackRoom}`);
- }
+ if (trackRoom) {
+ this.server
+ .to(artistRoom)
+ .to(trackRoom)
+ .emit('tip_notification', notificationData);
+ this.logger.log(
+ `Sent tip notification ${storedEvent.sequenceId} to rooms: ${artistRoom}, ${trackRoom}`,
+ );
+ } else {
+ this.server.to(artistRoom).emit('tip_notification', notificationData);
+ this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Send to all connected clients for global notifications | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.server.emit('global_tip_notification', notificationData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent global tip notification`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Sent global tip notification ${storedEvent.sequenceId}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.error(`Failed to send tip notification: ${error.message}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Replay missed events for a client | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private replayMissedEvents(client: Socket, lastSequenceId: number, rooms: string[]): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const missedEvents = this.eventStore.getEventsAfter(lastSequenceId, rooms); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (missedEvents.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.logger.log(`Replaying ${missedEvents.length} missed events for client ${client.id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const event of missedEvents) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const notificationData: TipNotificationData = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'tip_received', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sequenceId: event.sequenceId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: event.data, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client.emit('tip_notification', notificationData); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Send general notification | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { WebSocketGateway } from './websocket.gateway'; | ||
| import { EventStoreService } from './event-store.service'; | ||
|
|
||
| @Module({ | ||
| providers: [WebSocketGateway], | ||
| exports: [WebSocketGateway], | ||
| providers: [WebSocketGateway, EventStoreService], | ||
| exports: [WebSocketGateway, EventStoreService], | ||
| }) | ||
| export class WebSocketModule {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sanitize nested metadata before persisting it.
These fields are length/type checked, but unlike
messagethey never run through@SanitiseAsPlainText(). Line 182 ofbackend/src/tips/tips.service.tsthen serializes them directly, so HTML/control characters still reach storage.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents