feat(api): add terminal WebSocket gateway with PTY session management (#515)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #515.
This commit is contained in:
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* TerminalGateway
|
||||
*
|
||||
* WebSocket gateway for real-time PTY terminal sessions.
|
||||
* Uses the `/terminal` namespace to keep terminal traffic separate
|
||||
* from the main WebSocket gateway.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Client connects with auth token in handshake
|
||||
* 2. Client emits `terminal:create` to spawn a new PTY session
|
||||
* 3. Server emits `terminal:created` with { sessionId }
|
||||
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
||||
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
||||
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
||||
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
||||
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
||||
*
|
||||
* Authentication:
|
||||
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
||||
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
||||
*
|
||||
* Workspace isolation:
|
||||
* - Clients join room `terminal:{workspaceId}` on connect
|
||||
* - Sessions are scoped to workspace; cross-workspace access is denied
|
||||
*/
|
||||
|
||||
import {
|
||||
WebSocketGateway as WSGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from "@nestjs/websockets";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import {
|
||||
CreateTerminalDto,
|
||||
TerminalInputDto,
|
||||
TerminalResizeDto,
|
||||
CloseTerminalDto,
|
||||
} from "./terminal.dto";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Gateway
|
||||
// ==========================================
|
||||
|
||||
@WSGateway({
|
||||
namespace: "/terminal",
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(TerminalGateway.name);
|
||||
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly terminalService: TerminalService
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// Connection lifecycle
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Authenticate client on connection using handshake token.
|
||||
* Validates workspace membership and joins the workspace-scoped room.
|
||||
*/
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!authenticatedClient.data.userId) {
|
||||
this.logger.warn(
|
||||
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication timed out.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}, this.CONNECTION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const token = this.extractTokenFromHandshake(authenticatedClient);
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no token provided.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = await this.authService.verifySession(token);
|
||||
|
||||
if (!sessionData) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: invalid or expired token.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = sessionData.user as { id: string };
|
||||
const userId = user.id;
|
||||
|
||||
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId },
|
||||
select: { workspaceId: true, userId: true, role: true },
|
||||
});
|
||||
|
||||
if (!workspaceMembership) {
|
||||
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no workspace access.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
authenticatedClient.data.userId = userId;
|
||||
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
||||
|
||||
// Join workspace-scoped terminal room
|
||||
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
||||
await authenticatedClient.join(room);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.error(
|
||||
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: an unexpected error occurred.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all PTY sessions for this client's workspace on disconnect.
|
||||
*/
|
||||
handleDisconnect(client: Socket): void {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { workspaceId, userId } = authenticatedClient.data;
|
||||
|
||||
if (workspaceId) {
|
||||
this.terminalService.closeWorkspaceSessions(workspaceId);
|
||||
|
||||
const room = this.getWorkspaceRoom(workspaceId);
|
||||
void authenticatedClient.leave(room);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Terminal events
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Spawn a new PTY session for the connected client.
|
||||
*
|
||||
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
||||
* Emits `terminal:error` on failure.
|
||||
*/
|
||||
@SubscribeMessage("terminal:create")
|
||||
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate DTO
|
||||
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.terminalService.createSession(authenticatedClient, {
|
||||
workspaceId,
|
||||
socketId: authenticatedClient.id,
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
||||
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
||||
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
||||
});
|
||||
|
||||
authenticatedClient.emit("terminal:created", {
|
||||
sessionId: result.sessionId,
|
||||
name: result.name,
|
||||
cols: result.cols,
|
||||
rows: result.rows,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:input")
|
||||
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:resize")
|
||||
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and close an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:close")
|
||||
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const closed = this.terminalService.closeSession(dto.sessionId);
|
||||
if (!closed) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Private helpers
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Extract authentication token from Socket.IO handshake.
|
||||
* Checks auth.token, query.token, and Authorization header (in that order).
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const authHeader = client.handshake.headers.authorization as unknown;
|
||||
if (typeof authHeader === "string") {
|
||||
const parts = authHeader.split(" ");
|
||||
const [type, token] = parts;
|
||||
if (type === "Bearer" && token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace-scoped room name for the terminal namespace.
|
||||
*/
|
||||
private getWorkspaceRoom(workspaceId: string): string {
|
||||
return `terminal:${workspaceId}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user