Skip to content

Commit 834abaf

Browse files
committed
fix(realtime): gate WS on API role (P1), validate enqueue payload via class-validator (P2)
1 parent 6911276 commit 834abaf

2 files changed

Lines changed: 67 additions & 20 deletions

File tree

Packages/control-plane/src/modules/realtime/realtime.gateway.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Inject, forwardRef } from '@nestjs/common';
22
import type { OnModuleInit } from '@nestjs/common';
3+
import { plainToInstance } from 'class-transformer';
4+
import { validate } from 'class-validator';
35
import {
46
WebSocketGateway,
57
WebSocketServer,
@@ -12,10 +14,10 @@ import type { MatchAssignedChannel } from '../../infra/channels/match-assigned-c
1214
import { isKickPayload, NODE_INBOX_CHANNEL } from '../../infra/channels/node-inbox-channel.interface';
1315
import type { NodeInboxChannel } from '../../infra/channels/node-inbox-channel.interface';
1416
import { NODE_ID } from '../../infra/config/env.config';
17+
import { getMatchmakingRole, isApiEnabled } from '../matchmaking/matchmaking-role';
1518
import { USER_SESSION_REGISTRY } from './user-session-registry.interface';
1619
import type { UserSessionRegistry } from './user-session-registry.interface';
17-
import { buildEnqueuedEnvelope, type WsErrorResponse } from './ws-envelope.dto';
18-
import type { WsEnqueueMessage } from './ws-envelope.dto';
20+
import { buildEnqueuedEnvelope, WsEnqueueMessageDto, type WsErrorResponse } from './ws-envelope.dto';
1921
import { Server, WebSocket as WsWebSocket } from 'ws';
2022
import { IncomingMessage } from 'http';
2123

@@ -79,6 +81,10 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
7981
}
8082

8183
handleConnection(client: WsWebSocket, request: IncomingMessage) {
84+
if (!isApiEnabled(getMatchmakingRole())) {
85+
client.close(4403, 'This node does not serve WebSocket connections (queue-worker role)');
86+
return;
87+
}
8288
const url = new URL(request.url ?? '', `http://${request.headers.host}`);
8389
const ticketId = url.searchParams.get('ticketId');
8490
const userId = url.searchParams.get('userId');
@@ -113,27 +119,38 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
113119

114120
private setupMessageHandler(client: WsWebSocket): void {
115121
client.on('message', (data: Buffer | string) => {
116-
let msg: unknown;
117-
try {
118-
msg = JSON.parse(data.toString());
119-
} catch {
120-
this.sendError(client, 'Invalid JSON');
121-
return;
122-
}
123-
const m = msg as { action?: string };
124-
if (m?.action === 'enqueue') {
125-
this.handleEnqueue(client, m as WsEnqueueMessage);
126-
return;
127-
}
128-
if (m?.action === 'heartbeat') {
129-
this.handleHeartbeat(client);
130-
return;
131-
}
132-
this.sendError(client, 'Expected action: enqueue or heartbeat');
122+
void (async () => {
123+
let msg: unknown;
124+
try {
125+
msg = JSON.parse(data.toString());
126+
} catch {
127+
this.sendError(client, 'Invalid JSON');
128+
return;
129+
}
130+
const m = msg as { action?: string };
131+
if (m?.action === 'enqueue') {
132+
const dto = plainToInstance(WsEnqueueMessageDto, m);
133+
const errors = await validate(dto);
134+
if (errors.length > 0) {
135+
const detail = errors
136+
.flatMap((e) => Object.values(e.constraints ?? {}))
137+
.join(', ');
138+
this.sendError(client, `Invalid enqueue payload: ${detail}`);
139+
return;
140+
}
141+
await this.handleEnqueue(client, dto);
142+
return;
143+
}
144+
if (m?.action === 'heartbeat') {
145+
this.handleHeartbeat(client);
146+
return;
147+
}
148+
this.sendError(client, 'Expected action: enqueue or heartbeat');
149+
})();
133150
});
134151
}
135152

136-
private async handleEnqueue(client: WsWebSocket, m: WsEnqueueMessage): Promise<void> {
153+
private async handleEnqueue(client: WsWebSocket, m: WsEnqueueMessageDto): Promise<void> {
137154
try {
138155
const result = await this.matchmakingService.enqueue(
139156
{

Packages/control-plane/src/modules/realtime/ws-envelope.dto.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
12
import { AssignmentResult } from '../../infra/contracts/assignment.dto';
23

34
/** Server-to-client WebSocket event types. */
@@ -63,6 +64,35 @@ export interface WsEnqueueMessage {
6364
constraints?: Record<string, unknown>;
6465
}
6566

67+
/** Validated DTO for WsEnqueueMessage. Use plainToInstance + validate before calling handleEnqueue. */
68+
export class WsEnqueueMessageDto implements WsEnqueueMessage {
69+
action!: 'enqueue';
70+
71+
@IsOptional()
72+
@IsString()
73+
groupId?: string;
74+
75+
@IsNotEmpty({ message: 'queueKey is required' })
76+
@IsString()
77+
queueKey!: string;
78+
79+
@IsNotEmpty({ message: 'members is required' })
80+
@IsArray()
81+
@IsString({ each: true })
82+
members!: string[];
83+
84+
@IsInt()
85+
@Min(1)
86+
groupSize!: number;
87+
88+
@IsOptional()
89+
@IsString()
90+
region?: string;
91+
92+
@IsOptional()
93+
constraints?: Record<string, unknown>;
94+
}
95+
6696
/** Client-to-server: heartbeat to refresh ClusterDirectory lease. */
6797
export interface WsHeartbeatMessage {
6898
action: 'heartbeat';

0 commit comments

Comments
 (0)