import type { ServerWebSocket } 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. ── The upstream is a WEBSOCKET, not a raw TCP socket ── This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the reason is worth keeping because it is not a preference. **A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the platform watched that port arrive at registration, which is the only thing separating it from a number a manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket — the mechanism the fleet has spent months retiring. Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB stream and the platform never learns that RFB exists. This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's boundaries carry no meaning either way. The ignorance simply sits one layer up. */ export type PluginWSData = { provider: 'plugin-ws'; plugin: string; /** `ws://127.0.0.1:/ws`, with the client's query minus its credential. */ upstream: string; userId: number; }; type Frame = string | Uint8Array; type Relay = { ws: WebSocket | null; queue: Frame[]; 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?.ws?.close(); } catch { /* already gone */ } try { ws.close(code, reason); } catch { /* already closing */ } }; export const pluginWsWebsocket = { 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 before the upstream is open — and an RFB client sends its protocol version immediately. Dropping those frames 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 = { ws: null, queue: [], done: false }; relays.set(ws, relay); let upstream: WebSocket; try { // The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts: // a request without this header did not come through the platform. upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never); } catch { closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`); return; } relay.ws = upstream; upstream.binaryType = 'arraybuffer'; upstream.onopen = () => { // Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold // the upstream connection open for as long as the plugin's process lives. if (relay.done) { try { upstream.close(); } catch { /* already gone */ } return; } for (const frame of relay.queue) upstream.send(frame); relay.queue.length = 0; }; upstream.onmessage = (ev) => { try { ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer)); } catch { /* client gone; the close handler tidies up */ } }; // A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that // has not booted, or a service the owner stopped. The client is told, and told which plugin. upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`); upstream.onclose = () => closeClient(ws, 1011, 'upstream closed'); }, message(ws: ServerWebSocket, message: string | Buffer) { const relay = relays.get(ws); if (!relay) return; const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array); if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame); else relay.queue.push(frame); }, close(ws: ServerWebSocket) { const relay = relays.get(ws); relays.delete(ws); if (relay) relay.done = true; try { relay?.ws?.close(); } 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 }); // The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is // not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever // that process logs. `X-Officer-User` carries the only part it needs. const forwarded = new URLSearchParams(url.search); forwarded.delete('token'); const query = forwarded.toString(); const ok = server.upgrade(req, { data: { provider: 'plugin-ws', plugin, upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`, 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 }); } }