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 }); });