import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { and, eq } from "drizzle-orm"; import IoRedis from "ioredis"; import { WebSocketServer, type WebSocket } from "ws"; import { z } from "zod"; import { db } from "@tasks/database/client"; import { chatChannelMembers, chatChannels, workspaceMembers, } from "@tasks/database/schema"; const uuid = z.string().uuid(); const ticketClaims = z .object({ iss: z.literal("echodo-web"), aud: z.literal("echodo-chat-v1"), scope: z.literal("chat:subscribe"), sub: uuid, workspaceId: uuid, exp: z.number().int().positive(), }) .strict(); export type ChatTicket = z.infer; const topicPattern = /^chat:v1:workspace:([0-9a-f-]{36}):channel:([0-9a-f-]{36})$/; const subscribe = z .object({ type: z.literal("subscribe"), channelId: uuid }) .strict(); const unsubscribe = z .object({ type: z.literal("unsubscribe"), channelId: uuid }) .strict(); const clientMessage = z.discriminatedUnion("type", [subscribe, unsubscribe]); const MAX_CONNECTIONS = 2000; const MAX_CHANNELS = 64; const MAX_MESSAGE_BYTES = 512; const MAX_BUFFERED_BYTES = 64 * 1024; const HEARTBEAT_MS = 15_000; const AUTH_TIMEOUT_MS = 5_000; export function verifyTicket(value: string, secret: string): ChatTicket | null { if (secret.length < 32 || value.length > 2048) return null; const parts = value.split("."); if ( parts.length !== 2 || !parts[0] || !/^[A-Za-z0-9_-]+$/.test(parts[0]) || !/^[A-Za-z0-9_-]+$/.test(parts[1]) ) return null; const expected = createHmac("sha256", secret).update(parts[0]).digest(); const supplied = Buffer.from(parts[1], "base64url"); if ( supplied.length !== expected.length || !timingSafeEqual(supplied, expected) ) return null; try { const parsed = ticketClaims.safeParse( JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")), ); if ( !parsed.success || parsed.data.exp <= Math.floor(Date.now() / 1000) || parsed.data.exp > Math.floor(Date.now() / 1000) + 60 ) return null; return parsed.data; } catch { return null; } } export function parseTopic( topic: string, ): { workspaceId: string; channelId: string } | null { const match = topicPattern.exec(topic); if ( !match || !uuid.safeParse(match[1]).success || !uuid.safeParse(match[2]).success ) return null; return { workspaceId: match[1], channelId: match[2] }; } type Lookup = ( workspaceId: string, channelId: string, userId: string, ) => Promise<{ kind: string; workspaceMember: boolean; channelMember: boolean; } | null>; export async function authorizeChannel( lookup: Lookup, workspaceId: string, channelId: string, userId: string, ): Promise { try { let timeout: ReturnType | undefined; const row = await Promise.race([ lookup(workspaceId, channelId, userId), new Promise((resolve) => { timeout = setTimeout(() => resolve(null), AUTH_TIMEOUT_MS); }), ]).finally(() => clearTimeout(timeout)); return ( !!row && row.workspaceMember && (row.kind === "public" || ((row.kind === "private" || row.kind === "direct") && row.channelMember)) ); } catch { return false; } } // Always query current membership; never cache authorization across notifications. const lookupChannel: Lookup = async (workspaceId, channelId, userId) => { const [row] = await db .select({ kind: chatChannels.kind, workspaceMember: workspaceMembers.userId, channelMember: chatChannelMembers.userId, }) .from(chatChannels) .leftJoin( workspaceMembers, and( eq(workspaceMembers.workspaceId, chatChannels.workspaceId), eq(workspaceMembers.userId, userId), ), ) .leftJoin( chatChannelMembers, and( eq(chatChannelMembers.workspaceId, chatChannels.workspaceId), eq(chatChannelMembers.channelId, chatChannels.id), eq(chatChannelMembers.userId, userId), ), ) .where( and( eq(chatChannels.workspaceId, workspaceId), eq(chatChannels.id, channelId), ), ) .limit(1); return row ? { kind: row.kind, workspaceMember: !!row.workspaceMember, channelMember: !!row.channelMember, } : null; }; type Peer = { socket: WebSocket; ticket: ChatTicket; channels: Set; busy: boolean; }; /** Separate port: Hocuspocus owns its existing HTTP/WebSocket upgrade handler. */ export async function startChatServer(): Promise { const secret = process.env.CHAT_TICKET_SECRET?.trim(); const redisUrl = process.env.REDIS_URL?.trim(); if (!secret || secret.length < 32 || !redisUrl) { console.warn( "[chat] disabled: CHAT_TICKET_SECRET (>=32 characters) and REDIS_URL required", ); return; } const port = Number(process.env.CHAT_PORT ?? 1235); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid CHAT_PORT"); const subscriber = new IoRedis(redisUrl, { maxRetriesPerRequest: 1 }); await subscriber.ping(); // Fail closed: never accept subscribers without Redis. const http = createServer((_req, res) => { res.writeHead(404).end(); }); const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES, perMessageDeflate: false, }); const peers = new Set(); const topicPeers = new Map>(); const send = (peer: Peer, value: object) => { if (peer.socket.readyState !== 1) return; if (peer.socket.bufferedAmount > MAX_BUFFERED_BYTES) { peer.socket.close(1013); return; } peer.socket.send(JSON.stringify(value)); }; const remove = (peer: Peer, topic: string) => { peer.channels.delete(topic); const set = topicPeers.get(topic); set?.delete(peer); if (set?.size === 0) { topicPeers.delete(topic); void subscriber.unsubscribe(topic).catch(() => {}); } }; const revoke = (peer: Peer, topic: string, channelId: string) => { remove(peer, topic); send(peer, { type: "revoked", channelId }); }; const deliver = async (topic: string) => { const ids = parseTopic(topic); if (!ids) return; for (const peer of [...(topicPeers.get(topic) ?? [])]) { if (!peer.channels.has(topic)) continue; if ( peer.ticket.exp <= Date.now() / 1000 || !(await authorizeChannel( lookupChannel, ids.workspaceId, ids.channelId, peer.ticket.sub, )) ) { revoke(peer, topic, ids.channelId); peer.socket.close(1008); continue; } // Deliberately ignore Redis envelope including message bodies and keys. if (peer.channels.has(topic)) send(peer, { type: "invalidate", channelId: ids.channelId }); } }; subscriber.on("message", (topic) => { void deliver(topic).catch(() => {}); }); subscriber.on("error", () => { for (const peer of peers) peer.socket.close(1011); }); subscriber.on("end", () => { for (const peer of peers) peer.socket.close(1011); }); http.on("upgrade", (req, socket, head) => { if ( peers.size >= MAX_CONNECTIONS || req.url?.length === undefined || req.url.length > 2300 ) { socket.destroy(); return; } let token: string | null; try { const url = new URL(req.url, "http://localhost"); token = url.pathname === "/chat" ? url.searchParams.get("ticket") : null; } catch { token = null; } const ticket = token ? verifyTicket(token, secret) : null; if (!ticket) { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, ticket), ); }); wss.on("connection", (socket: WebSocket, ticket: ChatTicket) => { const peer: Peer = { socket, ticket, channels: new Set(), busy: false }; peers.add(peer); const expiry = setTimeout( () => socket.close(1008), Math.max(0, ticket.exp * 1000 - Date.now()), ); expiry.unref(); socket.on("close", () => { clearTimeout(expiry); peers.delete(peer); for (const topic of [...peer.channels]) remove(peer, topic); }); socket.on("message", (bytes) => { if (peer.busy) { socket.close(1008); return; } peer.busy = true; void (async () => { if (ticket.exp <= Date.now() / 1000) { socket.close(1008); return; } let data: unknown; try { data = JSON.parse(bytes.toString()); } catch { socket.close(1008); return; } const parsed = clientMessage.safeParse(data); if (!parsed.success) { socket.close(1008); return; } const { channelId } = parsed.data; const topic = `chat:v1:workspace:${ticket.workspaceId}:channel:${channelId}`; if (parsed.data.type === "unsubscribe") { remove(peer, topic); return; } if (peer.channels.size >= MAX_CHANNELS && !peer.channels.has(topic)) { socket.close(1008); return; } if ( !(await authorizeChannel( lookupChannel, ticket.workspaceId, channelId, ticket.sub, )) ) { socket.close(1008); return; } if (peer.channels.has(topic)) return; if (!topicPeers.has(topic)) { try { await subscriber.subscribe(topic); } catch { socket.close(1011); return; } if (!topicPeers.has(topic)) topicPeers.set(topic, new Set()); } // Recheck after the Redis subscribe await: access may have been revoked meanwhile. if ( !(await authorizeChannel( lookupChannel, ticket.workspaceId, channelId, ticket.sub, )) ) { socket.close(1008); return; } if (socket.readyState !== 1) return; topicPeers.get(topic)?.add(peer); peer.channels.add(topic); send(peer, { type: "subscribed", channelId }); })() .catch(() => socket.close(1011)) .finally(() => { peer.busy = false; }); }); }); setInterval(() => { for (const peer of peers) { if (peer.busy) continue; peer.busy = true; void (async () => { if (peer.ticket.exp <= Date.now() / 1000) { peer.socket.close(1008); return; } for (const topic of [...peer.channels]) { const ids = parseTopic(topic); if ( !ids || !(await authorizeChannel( lookupChannel, ids.workspaceId, ids.channelId, peer.ticket.sub, )) ) { revoke(peer, topic, ids?.channelId ?? ""); peer.socket.close(1008); return; } } })() .catch(() => peer.socket.close(1011)) .finally(() => { peer.busy = false; }); } }, HEARTBEAT_MS).unref(); await new Promise((resolve, reject) => { http.once("error", reject); http.listen(port, "0.0.0.0", resolve); }); console.log(`[chat] listening on port ${port}`); }