fix(core-realtime): address Phase 2 code review (typing, error handling, params)
- Type socket.data.user via AppSocketData/AppSocket generics (no more any)
- Wrap Gate 1 authenticator call in try/catch so exceptions reject the
connection with connect_error instead of being swallowed
- Fix Gate 3 userScoped channel authorize: derive params.userId from the
authenticated socket user so owner-inbound is accepted
- Await io.close(callback) in stop() to ensure full shutdown
- Remove unused httpServer field from constructor (io already holds the ref)
- Extract CHANNEL_ROOM_PREFIX/channelRoom helper to channel-room.ts;
replace three `ch:${name}` magic strings; re-export from index
- Add JSDoc to ChannelScope explaining userScoped params/template convention
- Fix "allows subscribe + invokes handler with ctx" test: hoist received
outside beforeEach and assert ctx shape
- New test: rejects connection when authenticator throws
- New test: userScoped channel accepts inbound from owner, rejects from anon
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import type { Server as IOServer, Socket } from "socket.io";
|
||||
import type { Server as IOServer, Socket, DefaultEventsMap } from "socket.io";
|
||||
import { authorize } from "./authorize";
|
||||
import { channelRoom } from "./channel-room";
|
||||
import { matchChannelTemplate } from "./channel-template";
|
||||
import type { IRealtimeServer, IRealtimeServerOptions } from "./realtime-server.interface";
|
||||
|
||||
/** Shape of per-socket session data attached by Gate 1. */
|
||||
type AppSocketData = { user: { userId: string; roles: string[] } | null };
|
||||
|
||||
/** Fully-typed Socket alias so `socket.data.user` resolves to `AppSocketData["user"]`. */
|
||||
type AppSocket = Socket<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, AppSocketData>;
|
||||
|
||||
function parseCookies(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(";")) {
|
||||
@@ -14,13 +20,11 @@ function parseCookies(header: string): Record<string, string> {
|
||||
}
|
||||
|
||||
export class SocketIORealtimeServer implements IRealtimeServer {
|
||||
private readonly httpServer: HttpServer;
|
||||
private readonly io: IOServer;
|
||||
private readonly opts: IRealtimeServerOptions;
|
||||
|
||||
constructor(opts: IRealtimeServerOptions) {
|
||||
this.opts = opts;
|
||||
this.httpServer = opts.httpServer;
|
||||
this.io = opts.io;
|
||||
}
|
||||
|
||||
@@ -28,16 +32,26 @@ export class SocketIORealtimeServer implements IRealtimeServer {
|
||||
const { authenticator, registry } = this.opts;
|
||||
|
||||
// Gate 1: connect — read cookie, authenticate, attach user.
|
||||
this.io.use(async (socket, next) => {
|
||||
const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
|
||||
socket.data.user = await authenticator.authenticate({
|
||||
cookies,
|
||||
headers: socket.handshake.headers as Record<string, string>,
|
||||
});
|
||||
next();
|
||||
});
|
||||
// If the authenticator throws (e.g. malformed token), the connection is
|
||||
// rejected so the client receives `connect_error`.
|
||||
(this.io as IOServer<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, AppSocketData>).use(
|
||||
async (socket, next) => {
|
||||
try {
|
||||
const cookies = parseCookies(socket.handshake.headers.cookie ?? "");
|
||||
socket.data.user = await authenticator.authenticate({
|
||||
cookies,
|
||||
headers: socket.handshake.headers as Record<string, string>,
|
||||
});
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.io.on("connection", (rawSocket) => {
|
||||
const socket = rawSocket as AppSocket;
|
||||
|
||||
this.io.on("connection", (socket: Socket) => {
|
||||
// Gate 2: subscribe.
|
||||
socket.on("subscribe", async (requestedName: string, ack?: (r: unknown) => void) => {
|
||||
// Find a registered descriptor whose name (or template) matches requestedName.
|
||||
@@ -64,7 +78,7 @@ export class SocketIORealtimeServer implements IRealtimeServer {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(`ch:${requestedName}`);
|
||||
socket.join(channelRoom(requestedName));
|
||||
ack?.({ ok: true });
|
||||
});
|
||||
|
||||
@@ -76,7 +90,16 @@ export class SocketIORealtimeServer implements IRealtimeServer {
|
||||
ack?.({ ok: false, error: "invalid_input" });
|
||||
return;
|
||||
}
|
||||
const allowed = await authorize(entry.descriptor, {}, socket.data.user ?? null);
|
||||
|
||||
// For userScoped channels, derive params from the authenticated user
|
||||
// so the owner's own socket passes the `params.userId === user.userId` check.
|
||||
const scope = entry.descriptor.scope;
|
||||
const params: Record<string, string> =
|
||||
typeof scope === "object" && "userScoped" in scope
|
||||
? { userId: socket.data.user?.userId ?? "" }
|
||||
: {};
|
||||
|
||||
const allowed = await authorize(entry.descriptor, params, socket.data.user ?? null);
|
||||
if (!allowed) {
|
||||
ack?.({ ok: false, error: "forbidden" });
|
||||
return;
|
||||
@@ -96,6 +119,6 @@ export class SocketIORealtimeServer implements IRealtimeServer {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.io.close();
|
||||
await new Promise<void>((resolve) => this.io.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user