import type { ServerWebSocket, Socket } from 'bun'; import { resolveAuthToken } from '@@/auth-token'; import { isTokenBlacklisted } from 'officerdb'; import { isApiRequestAllowed } from '@@/permissions/authorize'; import { pluginWsTargetFor } from './target'; /* One websocket door for every plugin, so a plugin can have a socket at all. ── Why this exists ── Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern" became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and `plugins/desktop` could not do the same thing at all. The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the plugin name is a path segment rather than a route. ── What it does and does not read ── Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are forwarded exactly as they arrive. A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a line-oriented sidecar without knowing which is which. */ export type PluginWSData = { provider: 'plugin-ws'; plugin: string; host: string; port: number; userId: number; }; type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean }; const relays = new Map, Relay>(); const closeClient = (ws: ServerWebSocket, code: number, reason: string) => { const relay = relays.get(ws); relays.delete(ws); if (relay) relay.done = true; try { relay?.socket?.end(); } catch { /* already gone */ } try { ws.close(code, reason); } catch { /* already closing */ } }; export const pluginWsWebsocket = { async open(ws: ServerWebSocket) { /* The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that looks like a corrupt display rather than a lost packet. Learned by dev-proxy first. */ const relay: Relay = { socket: null, queue: [], done: false }; relays.set(ws, relay); try { const socket = await Bun.connect({ hostname: ws.data.host, port: ws.data.port, socket: { data: (_s, chunk) => { try { ws.send(chunk); } catch { /* client gone; the close handler tidies up */ } }, close: () => closeClient(ws, 1011, 'upstream closed'), error: () => closeClient(ws, 1011, 'upstream error'), }, }); // Between the await above and here the client may have gone. Writing to a socket nobody is reading // would keep the upstream connection open for as long as the plugin's process lives. if (relay.done) { socket.end(); return; } relay.socket = socket; for (const chunk of relay.queue) socket.write(chunk); relay.queue.length = 0; } catch { // A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has // not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin. closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`); } }, message(ws: ServerWebSocket, message: string | Uint8Array) { const relay = relays.get(ws); if (!relay) return; const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message; if (relay.socket) relay.socket.write(bytes); else relay.queue.push(bytes); }, close(ws: ServerWebSocket) { const relay = relays.get(ws); relays.delete(ws); if (relay) relay.done = true; try { relay?.socket?.end(); } catch { /* already gone */ } }, }; /** * `/api/plugin-ws/` with an upgrade header. * * Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather * than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole: * granting one plugin would grant them all. * * So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes * are asked — `isApiRequestAllowed(user, 'GET', '/api/')`. That is deliberate and it is the whole * safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a * plugin grants its socket, and revoking it closes it. * * Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but * Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist * check and the permission decision all happen before anything is upgraded. */ export async function upgradePluginWs(req: Request, server: any): Promise { const url = new URL(req.url); const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/); if (!match) return undefined; const plugin = match[1]!; // A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the // concession every other socket here already makes. Verified through the same resolver as the HTTP doors, // so an API key that works against /api works here too. const token = url.searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); try { const user = await resolveAuthToken(token); if (!user) return new Response('Unauthorized', { status: 401 }); if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 }); // The plugin's own gate, asked as if this were a GET against its HTTP prefix. if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) { return new Response('Forbidden', { status: 403 }); } const target = await pluginWsTargetFor(plugin); // 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to // discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's // recipient lookup. if (!target) return new Response('Not found', { status: 404 }); const ok = server.upgrade(req, { data: { provider: 'plugin-ws', plugin, host: target.host, port: target.port, userId: user.id, } satisfies PluginWSData, }); return ok ? undefined : new Response('Upgrade failed', { status: 400 }); } catch { // Any failure to establish WHO is calling denies. Never open a socket on a maybe. return new Response('Unauthorized', { status: 401 }); } }