Files
platform/src/servers/api/dev-proxy/router.ts
T
pastilhasandClaude Opus 5 ab2336516f dev-proxy: a stylesheet's url() is a URL
Two bugs, one line apart, both silent.

The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.

Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.

JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:42:14 +01:00

183 lines
8.5 KiB
TypeScript

import { createRouter } from '../../create-router';
import { resolveAuthToken } from '../../auth-token';
import { isSuperAdmin } from '../../super-admin';
import { rewriteAssetPaths, rewriteCss, 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://<platform>`. `http://localhost:5173` resolves
* on the VIEWER's machine, not the server's. `http://<server>: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<boolean> {
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}=`));
/**
* Take a normal authenticated request and turn it into the cookie the iframe needs.
*
* This exists because the two ways of getting credentials into a frame are both guesses. A token on the
* iframe URL guesses which key the browser stored it under; a cookie that must already be there guesses
* that something set it earlier. The panel's own API client already knows how to authenticate — it sends
* the Authorization header on every other call it makes — so it calls this first, and the iframe that
* follows needs to carry nothing at all.
*
* `Authorization` rather than the URL, so the token never appears in an address, a log or a Referer.
*/
devProxyRouter.get('/session', async (ctx) => {
const header = ctx.req.header('authorization') ?? '';
const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : null;
if (!(await isOwnerToken(bearer))) return unauthorised();
return new Response(JSON.stringify({ ok: true }), {
headers: {
'content-type': 'application/json',
'set-cookie': `${COOKIE}=1; Path=/api/dev-proxy; Max-Age=${COOKIE_TTL_S}; HttpOnly; SameSite=Lax`,
},
});
});
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 `/<port>` 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 isCss = type.includes('text/css');
const rewritable = isHtml || isCss || type.includes('javascript');
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) {
// `fetch` decompressed this on the way in, so the upstream's `content-encoding` now describes a body
// that no longer exists and `content-length` counts bytes that are no longer there. The browser would
// try to inflate plain bytes. The rewrite branch below has always deleted both; this one did not, so
// every image, font and wasm file was served with a lie attached.
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(res.body, { status: res.status, headers });
}
const text = await res.text();
const body = isHtml
? rewriteHtml(text, prefix)
: isCss
? rewriteCss(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 });
});