// /plugin-ui//** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get // here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the // logged-in operator, on the console's own origin, with no separate password. We look up the // plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie, // and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1. // // This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts // handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy). import { defineEventHandler, getProxyRequestHeaders, getRequestURL, readRawBody, sendWebResponse, setResponseStatus, } from "h3"; import { bustCredential, fetchUiCredential, PLUGIN_ID_RE, } from "../../util/pluginProxy"; export default defineEventHandler(async (event) => { const { pathname, search } = getRequestURL(event); // /plugin-ui// const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/); const id = m?.[1]; if (!id || !PLUGIN_ID_RE.test(id)) { setResponseStatus(event, 404); return { error: "not a valid plugin-ui path" }; } const rest = m?.[2] ?? "/"; const prefix = `/plugin-ui/${id}`; // Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the // session cookie so plugin code never sees it. const headers = getProxyRequestHeaders(event) as Record; delete headers.cookie; delete headers.authorization; headers["x-forwarded-prefix"] = prefix; const method = event.method; // Only read a body for the methods that can carry one. `readRawBody` asserts a payload method, // so calling it for OPTIONS (a plugin UI's CORS preflight, or any client probing Allow) threw // 405 out of the CONSOLE before the plugin was ever dialed. const body = BODY_METHODS.has(method) ? ((await readRawBody(event, false)) as Uint8Array | undefined) : undefined; // One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died). const attempt = async (bustCache: boolean): Promise => { const cred = await fetchUiCredential(id, { bustCache }); if (!cred) return null; const target = `http://127.0.0.1:${cred.port}${rest}${search}`; try { return await fetch(target, { method, headers: { ...headers, authorization: `Bearer ${cred.secret}` }, body: body as BodyInit | undefined, redirect: "manual", }); } catch { // The port is dead (plugin crashed/restarted on a new port): drop the stale credential so // the next request re-resolves it. bustCredential(id); return null; } }; let resp = await attempt(false); // Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once. if (resp?.status === 401) { const retry = await attempt(true); if (retry) resp = retry; } if (!resp) { setResponseStatus(event, 502); return { error: `plugin "${id}" is not running` }; } return sendWebResponse(event, sanitize(resp)); }); /** Methods that may carry a request body. Anything else (GET, HEAD, OPTIONS, TRACE) must not be * handed to `readRawBody`. */ const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** * Rebuild a plugin's response before it goes out on the console's own origin. * * An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it * returns is asserted for the console itself — and the first version of this dropped four names it * had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the * browser would honour it for this origin and wipe `pf_session`, signing the operator out of the * console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`, * `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us. * * So: name what a plugin page legitimately needs, and drop the rest. Framing headers * (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded * the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are * recomputed. */ const PLUGIN_HEADER_ALLOWLIST = new Set([ "content-type", "cache-control", "etag", "last-modified", "expires", "vary", "content-language", "content-disposition", "accept-ranges", "content-range", "location", // its own redirects, within its own prefix "link", // preload hints for its own assets "x-forwarded-prefix", ]); function sanitize(resp: Response): Response { const headers = new Headers(); for (const [k, v] of resp.headers) { if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v); } // 204/304 must not carry a body — passing one through throws in the Response constructor. const bodyless = resp.status === 204 || resp.status === 304; return new Response(bodyless ? null : resp.body, { status: resp.status, statusText: resp.statusText, headers, }); }