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>
This commit is contained in:
2026-08-16 12:59:20 +00:00
co-authored by Claude Opus 5
parent 83cd7af156
commit 7d64b0a0d6
9 changed files with 434 additions and 5 deletions
+10
View File
@@ -40,6 +40,12 @@ const EXEMPT_API_PREFIXES: Record<string, string> = {
// 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<string, string> = {
const EXEMPT_WS_PROVIDERS: Record<string, string> = {
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',
};
/**