diff --git a/src/server.tsx b/src/server.tsx index 93e07b20..730f3aab 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -25,6 +25,7 @@ import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './serv import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; +import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket'; // Build static file routes from public/ // A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file` @@ -138,6 +139,7 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket, msg: Recor const handlers: Record = { terminal: terminalWebsocket, + 'dev-proxy': devProxyWebsocket, chat: chatWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, @@ -395,6 +397,13 @@ const server = serve({ // if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server); // return honoServer.fetch(req, server); // }, + // A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than + // behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is + // an ordinary GET by then. + '/api/dev-proxy/*': (req: Request, server: any) => { + if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server); + return honoServer.fetch(req, server); + }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { data: { provider: 'sidecar', userId: 0, email: '', username: '' }, diff --git a/src/servers/api/dev-proxy/html-rewrite.ts b/src/servers/api/dev-proxy/html-rewrite.ts new file mode 100644 index 00000000..8c45cf7b --- /dev/null +++ b/src/servers/api/dev-proxy/html-rewrite.ts @@ -0,0 +1,59 @@ +// Make an app that thinks it is at `/` work when it is served under `/api/dev-proxy//`. +// +// ── Why this exists at all ── +// +// The browser is on https://, and the dev server is on a port of the SERVER's loopback. It +// cannot be reached directly: `localhost` in an iframe means the viewer's own machine, and `http://host:port` +// is mixed content inside an https page and is blocked. So the bytes come through the platform's origin, +// under a path prefix — and every absolute URL the app emits (`/chunk.js`, `fetch('/api/x')`, its HMR +// socket) points at the platform root instead of at itself. +// +// Rewriting the HTML covers what is written in the document. The injected script covers what is built at +// runtime, which is most of it in a modern dev server. +// +// Restored from `188b45c1^`, minus the JWT injection it used to do: those apps called the PLATFORM's API +// and needed the owner's token. A dev server proxied here talks to itself, and handing its process a +// bearer token for the platform would be giving credentials to whatever happens to be on that port. +export const proxyOverrideScript = (base: string) => + ``; + +// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override +export const rewriteHtml = (html: string, base: string) => + html + .replace(/]*)>/i, `${proxyOverrideScript(base)}`) + .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) + .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) + .replace(/url\(\//g, `url(${base}/`); + +// JS/CSS: only `/_bun/` paths. +// +// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are +// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it +// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to +// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that. +export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/api/dev-proxy/router.ts b/src/servers/api/dev-proxy/router.ts new file mode 100644 index 00000000..3dfb44c4 --- /dev/null +++ b/src/servers/api/dev-proxy/router.ts @@ -0,0 +1,145 @@ +import { createRouter } from '../../create-router'; +import { resolveAuthToken } from '../../auth-token'; +import { isSuperAdmin } from '../../super-admin'; +import { rewriteAssetPaths, rewriteHtml } from './html-rewrite'; + +/** + * See a dev server running on THIS machine's loopback, from a browser that is somewhere else. + * + * `GET /api/dev-proxy/5173/…` forwards to `http://127.0.0.1:5173/…`. That is the whole feature. + * + * ── Why a proxy rather than pointing the iframe at the port ── + * + * Neither obvious approach works from a browser on `https://`. `http://localhost:5173` resolves + * on the VIEWER's machine, not the server's. `http://:5173` is mixed content inside an https page + * and the browser blocks it. So the bytes have to arrive over the platform's own origin, which is what + * this is — and it costs no certificate, no proxy-host entry, and no port opened to the outside. + * + * A dev server can therefore stay bound to 127.0.0.1. The alternative in use before this — a proxy host + * per app — needs the server bound to 0.0.0.0 and a firewall rule for the docker bridge. + * + * ── The cost, stated plainly ── + * + * The app believes it is at `/`, and it is not. Absolute paths are rewritten on the way out + * (`html-rewrite.ts`), which handles Bun's dev server and anything using ordinary relative or root-absolute + * assets. It will not handle an app that builds URLs in ways the rewrite cannot see. For those, the answer + * is the app's own `base` setting, or a real hostname — not more regexes here. + * + * ── Access ── + * + * This route reaches ANY port on the machine's loopback, so it cannot be open. An iframe cannot carry a + * bearer token — it is a navigation, not a fetch — so the panel puts the token in the URL once, and this + * exchanges it for a short-lived cookie that every sub-resource then carries automatically. Same origin as + * the platform, so the cookie needs no domain games and never leaves it. + */ +export const devProxyRouter = createRouter(); + +/** Ports only, and only real ones. This string ends up in a URL that gets fetched. */ +const PORT_RE = /^[0-9]{2,5}$/; + +const COOKIE = 'officer_dev_proxy'; + +/** + * How long a preview stays open without re-authenticating. + * + * Short, because it is a bearer of "read any loopback port". Long enough that a session of watching a dev + * server does not blink. The panel re-issues it on every reload, so the ceiling is rarely reached. + */ +const COOKIE_TTL_S = 12 * 60 * 60; + +const unauthorised = () => + new Response('Not authorised for the dev proxy. Open it from the Preview panel.', { status: 401 }); + +/** + * Prove the caller is the OWNER, once, from the `?token=` the panel puts on the iframe's URL. + * + * Through `resolveAuthToken` — the one function that turns a bearer string into a caller, which every + * other door already uses — rather than a second verification written here. This route is reachable + * without the permission gate by construction, so its check has to be the real one. + * + * Owner only, and not because of a permission entry: reading an arbitrary loopback port is exactly the + * `execution` power the registry says can never be granted to anyone else. + */ +async function isOwnerToken(token: string | null): Promise { + if (!token) return false; + try { + const identity = await resolveAuthToken(token); + return identity ? await isSuperAdmin({ id: identity.id }) : false; + } catch { + // Every failure denies. A transient database error must not open a proxy to the whole loopback. + return false; + } +} + +const hasCookie = (req: Request): boolean => + (req.headers.get('cookie') ?? '').split(';').some((part) => part.trim().startsWith(`${COOKIE}=`)); + +devProxyRouter.all('/:port/*', async (ctx) => { + const port = ctx.req.param('port'); + if (!PORT_RE.test(port)) return new Response('Bad port', { status: 400 }); + + const url = new URL(ctx.req.url); + const token = url.searchParams.get('token'); + const authorised = hasCookie(ctx.req.raw) || (await isOwnerToken(token)); + if (!authorised) return unauthorised(); + + const prefix = `/api/dev-proxy/${port}`; + // Everything after the `/` segment — what the dev server should see as its own path. + // + // Found rather than assumed. Slicing `prefix.length` off the pathname hardcodes the mount point, and a + // router that is wrong when mounted somewhere else is a router that is wrong: the first version of this + // returned the index page for every asset request, because the arithmetic was off by the length of the + // prefix it had guessed. + const marker = `/${port}`; + const at = url.pathname.indexOf(marker); + const path = at === -1 ? '/' : url.pathname.slice(at + marker.length) || '/'; + + // The token never reaches the dev server, and never stays in the address the app sees. + url.searchParams.delete('token'); + const search = url.searchParams.toString(); + + let res: Response; + try { + const headers = new Headers(ctx.req.raw.headers); + headers.delete('host'); + headers.delete('authorization'); + headers.delete('cookie'); + res = await fetch(`http://127.0.0.1:${port}${path}${search ? `?${search}` : ''}`, { + method: ctx.req.method, + headers, + body: ctx.req.method === 'GET' || ctx.req.method === 'HEAD' ? undefined : ctx.req.raw.body, + redirect: 'manual', + }); + } catch { + // The most common state by far: nothing is listening yet. Said in words rather than as a 502 with an + // empty body, because this renders inside the panel and is the whole of what the user sees. + return new Response(`Nothing is listening on 127.0.0.1:${port} on the server.`, { + status: 502, + headers: { 'content-type': 'text/plain' }, + }); + } + + const type = res.headers.get('content-type') ?? ''; + const isHtml = type.includes('text/html'); + const rewritable = isHtml || type.includes('javascript') || type.includes('text/css'); + + const headers = new Headers(res.headers); + // Anything framed here is the owner's own dev server; the platform frames it deliberately. + headers.delete('x-frame-options'); + headers.delete('content-security-policy'); + + // Set on the HTML response only — that is the navigation the panel drives, and every sub-resource + // inherits it. `Lax` is enough: the frame and the platform are the same site. + if (isHtml && token) { + headers.append('set-cookie', `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`); + } + + if (!rewritable) return new Response(res.body, { status: res.status, headers }); + + const text = await res.text(); + const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix); + // Both are wrong after a rewrite: the length changed, and the body was decoded on the way through. + headers.delete('content-length'); + headers.delete('content-encoding'); + return new Response(body, { status: res.status, headers }); +}); diff --git a/src/servers/api/dev-proxy/websocket.ts b/src/servers/api/dev-proxy/websocket.ts new file mode 100644 index 00000000..cddffd45 --- /dev/null +++ b/src/servers/api/dev-proxy/websocket.ts @@ -0,0 +1,118 @@ +import type { ServerWebSocket } from 'bun'; + +/** + * The HMR socket, bridged. + * + * Without this the proxy serves a dev server that renders once and then goes quiet: the page loads, the + * agent edits a file, and nothing moves. That is the state the app-preview panel already has a documented + * bug about, and it is worse here because it would be permanent rather than a stale connection. + * + * The injected script rewrites any same-host websocket URL under the proxy prefix (`html-rewrite.ts`), so + * `ws:///_bun/hmr` arrives here as `/api/dev-proxy/5173/_bun/hmr`. This dials + * `ws://127.0.0.1:5173/_bun/hmr` and relays frames both ways, in both directions, unmodified. + * + * ── Shape copied from the vault relay, including the part that is not obvious ── + * + * State is registered SYNCHRONOUSLY in `open`, before the upstream connection is dialled, and frames that + * arrive in the gap are queued rather than dropped. Bun requires the upgrade itself to be synchronous, so + * there is always a window where the client can speak before the upstream exists — and a dev server's + * client says hello immediately. + */ +export type DevProxyWSData = { provider: 'dev-proxy'; port: string; path: string }; + +type Upstream = { ws: WebSocket | null; queue: (string | ArrayBuffer)[]; ready: boolean; closed: boolean }; + +const upstreams = new WeakMap, Upstream>(); + +const closeClient = (ws: ServerWebSocket, state: Upstream, code: number, reason: string) => { + state.closed = true; + try { + ws.close(code, reason); + } catch { + /* already gone */ + } +}; + +export const devProxyWebsocket = { + open(ws: ServerWebSocket) { + const state: Upstream = { ws: null, queue: [], ready: false, closed: false }; + upstreams.set(ws, state); + + const { port, path } = ws.data; + let upstream: WebSocket; + try { + upstream = new WebSocket(`ws://127.0.0.1:${port}${path}`); + } catch { + return closeClient(ws, state, 1011, 'dev server not reachable'); + } + upstream.binaryType = 'arraybuffer'; + state.ws = upstream; + + upstream.addEventListener('open', () => { + state.ready = true; + for (const frame of state.queue) upstream.send(frame); + state.queue = []; + }); + upstream.addEventListener('message', (event) => { + if (state.closed) return; + try { + ws.send(event.data as string | ArrayBuffer); + } catch { + /* client gone */ + } + }); + // A dev server that restarts drops this socket, and the page must be told: its HMR client reconnects + // on close, which is what makes `bun dev` restarts recover by themselves. + upstream.addEventListener('close', () => closeClient(ws, state, 1000, 'dev server closed')); + upstream.addEventListener('error', () => closeClient(ws, state, 1011, 'dev server error')); + }, + + message(ws: ServerWebSocket, raw: string | Buffer) { + const state = upstreams.get(ws); + if (!state || state.closed) return; + const frame = typeof raw === 'string' ? raw : new Uint8Array(raw).buffer; + if (!state.ready || !state.ws) { + state.queue.push(frame as string | ArrayBuffer); + return; + } + try { + state.ws.send(frame as string | ArrayBuffer); + } catch { + /* upstream gone; its close handler will tidy up */ + } + }, + + close(ws: ServerWebSocket) { + const state = upstreams.get(ws); + if (!state) return; + state.closed = true; + try { + state.ws?.close(); + } catch { + /* already closed */ + } + upstreams.delete(ws); + }, +}; + +/** `/api/dev-proxy//` with an upgrade header. Cookie-gated, exactly as the HTTP side is. */ +export function upgradeDevProxyWs(req: Request, server: any): Response | undefined { + const url = new URL(req.url); + const match = url.pathname.match(/^\/api\/dev-proxy\/([0-9]{2,5})(\/.*)?$/); + if (!match) return undefined; + + // Same credential as every other request the page makes — the cookie the HTML response set. An upgrade + // carries cookies (it is a normal GET first), so nothing extra is needed here. + if (!(req.headers.get('cookie') ?? '').includes('officer_dev_proxy=')) { + return new Response('Not authorised', { status: 401 }); + } + + const ok = server.upgrade(req, { + data: { + provider: 'dev-proxy', + port: match[1]!, + path: `${match[2] ?? '/'}${url.search}`, + } satisfies DevProxyWSData, + }); + return ok ? undefined : new Response('Upgrade failed', { status: 400 }); +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 9e3d5df7..eefc7fed 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -19,6 +19,7 @@ import { uploadRouter } from './api/upload/upload'; import { settingsRouter } from './api/settings/settings'; import { dashboardsRouter } from './api/dashboards'; import { router as fileBrowserRouter } from './api/file-browser/router'; +import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; // import { vaultRouter } from './api/vault/router'; // import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; @@ -138,6 +139,8 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/gitea', giteaRouter], // plugin — switched off 2026-08-13 ['/app-store', appStoreRouter], ['/plugins', pluginsRouter], + // Loopback dev servers, framed by the Preview panel. See its router for why it is not a proxy host. + ['/dev-proxy', devProxyRouter], // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 diff --git a/src/servers/permissions/totality.ts b/src/servers/permissions/totality.ts index 73a3ebea..fe91820c 100644 --- a/src/servers/permissions/totality.ts +++ b/src/servers/permissions/totality.ts @@ -40,6 +40,12 @@ const EXEMPT_API_PREFIXES: Record = { // It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is // the whole justification: this is not a general API door with a second credential, it is one verb. '/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT', + // An iframe cannot send an Authorization header — it is a navigation, not a fetch — so the permission + // gate has nothing to read and would 401 the preview before it loaded. The route is NOT open: it + // verifies the owner's token from the URL once and issues a short-lived cookie, and it refuses anyone + // who is not the owner. That check lives in the router because it is the only door here, which is + // exactly the shape this list exists to make somebody justify. + '/dev-proxy': 'iframes cannot carry a bearer token; the router itself verifies the owner and sets a cookie', }; /** @@ -48,6 +54,10 @@ const EXEMPT_API_PREFIXES: Record = { const EXEMPT_WS_PROVIDERS: Record = { sidecar: 'sidecar registration, loopback process-to-process, no user', vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token', + // The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the + // same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner + // without carrying an account through `upgradeWs`. Refused outright without that cookie. + 'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues', }; /** diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx index 6c717a6e..4bb66ba3 100644 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx +++ b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Globe } from 'lucide-react'; import { useWorkspace } from '../../components/Workspace'; -import { normalizeUrl, usePreview } from './use-preview'; +import { iframeSrc, normalizeUrl, usePreview } from './use-preview'; /** * A live app, in a panel. @@ -54,5 +54,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => { ); } - return