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:
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user