Files
platform/src/servers/api/dev-proxy/router.ts
T
pastilhasandClaude Opus 5 7d64b0a0d6 paste localhost:3000 into the preview and see the server's port
No proxy host, no certificate, no port opened. `/api/dev-proxy/5173/…` forwards to
`http://127.0.0.1:5173/…` over the platform's own origin, and the panel maps what
you type onto it.

── Why a proxy at all, when the ask was "just point at localhost" ──

Neither direct route works from a browser on https://<platform>.
`http://localhost:5173` resolves on the VIEWER's machine, not the server's, and
`http://<server>:5173` is mixed content inside an https page and is blocked before
it is even attempted. Both fail looking exactly like the dev server being down. The
bytes have to arrive over an origin the page already trusts, which is the platform.

A dev server can therefore stay bound to 127.0.0.1. The workflow this replaces —
one NPM proxy host per app — needs it on 0.0.0.0 plus a firewall rule for the
docker bridge, so this is both less setup and less exposure.

── What came back from `188b45c1^`, and what did not ──

The 46-line HTML/asset rewrite, which is the hard-won part: absolute paths in the
document, plus an injected script that intercepts fetch/XHR/WebSocket for the URLs
built at runtime. Its JWT injection is deleted — those apps called the PLATFORM's
API; a dev server proxied here talks to itself, and handing its process the owner's
bearer token would be giving credentials to whatever is on that port.

The other 314 lines are not back: spawning, port discovery, idle timers, log
collection, a proxyId registry. The port is the id, and the background-job button
already starts dev servers.

── HMR is bridged, because "see it" without "follow it" is half the feature ──

The injected script rewrites the dev server's socket under the prefix; the relay
dials `ws://127.0.0.1:<port>` and forwards frames both ways. Same shape as the vault
relay including the non-obvious part — state registered synchronously and frames
queued until the upstream opens, because Bun requires the upgrade to be synchronous
and an HMR client speaks immediately. An upstream close closes the client, which is
what makes a dev-server restart reconnect by itself.

── Access, which was not in the ask and is not optional ──

This route reaches any loopback port on the machine, on a host that answers from the
public internet. An iframe cannot carry a bearer token — it is a navigation — so the
panel puts it in the URL once, the proxy verifies it is the OWNER through
`resolveAuthToken`, and issues a short-lived cookie every sub-resource then carries.
Anonymous and non-owner requests get 401, verified. Owner-only rather than a
permission: reading an arbitrary loopback port is the `execution` power the registry
says can never be granted.

Both exemptions are registered with reasons, and the boot check earned its keep —
it refused to start until the socket provider was claimed too.

── Verified against a real loopback-only server ──

Confirmed unreachable from outside, then through the proxy: index served and
rewritten, `/_bun/` chunk resolved, JSON passed through untouched, 401 anonymous,
401 with a bad token, "nothing is listening" on a dead port. Websocket: refused
without the cookie, target parsed, frames relayed in both directions.

One bug found by testing rather than reading: the upstream path was derived by
slicing a hardcoded prefix, so every asset request returned the index page. It is
found from the `/<port>` segment now.

A test asserting `http://localhost:5173` is kept verbatim is now inverted, with the
reason written beside it — that was correct when the panel only framed real
hostnames.

tsgo clean, frontend builds, 890 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:59:20 +00:00

146 lines
6.8 KiB
TypeScript

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://<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}=`));
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 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 });
});