fix: print the dashboard token with the dev tunnel URL

`pnpm dev --tunnel` published a bare URL under a "public, unauthenticated"
header. That label was wrong for the flag's own default target: --tunnel with
no port aims at the dashboard, which is bearer-token gated, so the recipient
of a shared link got a 401 with no token to supply.

resolveDevTunnelAuth() now classifies the target and the banner says what is
actually true of it:

  token         dashboard with auth on — prints the token and a ?token= link,
                resolved from FUSION_DASHBOARD_TOKEN, FUSION_DAEMON_TOKEN,
                then ~/.fusion/settings.json
  token-pending first run, token not minted yet — defers to the dashboard's
                own startup banner
  no-auth       --no-auth is on; the dashboard really is open
  foreign       a non-dashboard port; Fusion has no auth to lend it, the only
                genuinely ungated case

Auth resolves at banner time rather than flag-parse time so a token the dev
child mints on a first authenticated run is already readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 18:21:50 -07:00
parent 4c545676b8
commit ee57f8a3b9
5 changed files with 192 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: `pnpm dev --tunnel` now prints the dashboard token and a ready-to-open link for the tunnel URL.
category: fix
dev: The tunnel banner previously printed a bare URL labelled "public, unauthenticated", which was wrong for its own default target: `--tunnel` with no port aims at the dashboard, which is bearer-token gated, so the recipient hit a 401 with no token to supply. `resolveDevTunnelAuth` now classifies the target as `token` (dashboard with auth on — prints the token plus a `?token=` link, resolved from `FUSION_DASHBOARD_TOKEN`/`FUSION_DAEMON_TOKEN`/`~/.fusion/settings.json`), `token-pending` (first run, token not minted yet — defers to the dashboard's own banner), `no-auth`, or `foreign` (a non-dashboard port, the only case that is genuinely ungated). Auth is resolved at banner time, after the dev child has started, so a freshly minted token is already readable.

View File

@@ -102,10 +102,23 @@ pnpm dev --tunnel dashboard # tunnel the default port AND run the dashboard
FUSION_DEV_TUNNEL=1 pnpm dev # same, from the environment
```
Tunnelling the dashboard (the default) prints the bearer token and a link that already carries it,
because a tunnel URL you cannot open is not a shared dev server:
```
┌ dev server tunnel (public, unauthenticated)
┌ dev server tunnel
│ https://mic-relatively-jewelry-belly.trycloudflare.com → http://localhost:4040
│ token: fn_1a2b3c…
│ ready-to-open: https://mic-relatively-jewelry-belly.trycloudflare.com/?token=fn_1a2b3c…
└ that link carries the token — share it only with whoever should have access
```
Tunnelling any other port has no Fusion auth to lend it, and says so:
```
┌ dev server tunnel
│ https://mic-relatively-jewelry-belly.trycloudflare.com → http://localhost:5173
└ anyone with this URL can reach your dev server
└ anyone with this URL can reach that port — Fusion adds no auth to it
```
Requires `cloudflared` on PATH (the Docker image ships it). Quick tunnels need no account, domain, or
@@ -115,9 +128,14 @@ for HTTP.
Behaviour worth knowing:
- **The URL is public and unauthenticated.** Anyone holding it reaches the dev server. Use it for
sharing a preview, not for anything sensitive. Tunnelling the DASHBOARD port is different — the
dashboard enforces its own bearer token, so a tunnel to it still returns 401 without credentials.
- **What guards the URL depends on the target.** A tunnel to the DASHBOARD port is still behind the
dashboard's bearer token (the banner prints it and a token-bearing link — treat that link as the
credential it is). A tunnel to any OTHER port is genuinely open: anyone holding the URL reaches it,
so use it for sharing a preview, not for anything sensitive. `--no-auth` opens the dashboard too,
and the banner says so.
- **The token comes from the same place the dashboard's does** — `FUSION_DASHBOARD_TOKEN`,
`FUSION_DAEMON_TOKEN`, then `~/.fusion/settings.json`. On a first authenticated run the token may
not exist yet when the tunnel comes up; the banner then points at the dashboard's own startup line.
- **A failed tunnel never takes the dev server down.** If `cloudflared` is missing or no URL is
published, it logs and carries on; losing a preview URL must not cost you your dev loop.
- **Restarts reuse the tunnel.** In `--watch` mode a fresh quick tunnel would hand out a different

View File

@@ -9,7 +9,11 @@ import {
resolveDevTunnelPort,
resolvePrebuildMode,
} from "../../../../scripts/dev-with-memory-lib.mjs";
import { extractQuickTunnelUrl } from "../../../../scripts/lib/dev-tunnel.mjs";
import {
extractQuickTunnelUrl,
formatDevTunnelBanner,
resolveDevTunnelAuth,
} from "../../../../scripts/lib/dev-tunnel.mjs";
import {
createDevSourceWatcher,
isRestartableSourceFile,
@@ -371,5 +375,53 @@ describe("development source restart watcher", () => {
expect(extractQuickTunnelUrl("INF | https://neat-fox-tree.trycloudflare.com |")).toBe("https://neat-fox-tree.trycloudflare.com");
expect(extractQuickTunnelUrl("INF Registered tunnel connection")).toBeNull();
});
/*
FNXC:DevTunnel 2026-08-19-01:18:
The banner must state the target's ACTUAL auth. The default target is the dashboard, which is
bearer-token gated, so a bare URL is unusable by the person it was shared with — and the old
"public, unauthenticated" wording was wrong for exactly that default. Only a foreign port (a
Vite server, say) is genuinely ungated, because Fusion has no auth to lend it.
*/
const auth = (over: Record<string, unknown> = {}) => resolveDevTunnelAuth({
port: 4040,
dashboardPort: 4040,
env: {},
settingsFile: "/nonexistent/settings.json",
readToken: () => null,
...over,
});
it("lends the dashboard token to a tunnel aimed at the dashboard", () => {
expect(auth({ readToken: () => "fn_abc" })).toEqual({ kind: "token", token: "fn_abc" });
expect(auth({ env: { FUSION_DASHBOARD_TOKEN: "fn_env" }, readToken: () => "fn_disk" }))
.toEqual({ kind: "token", token: "fn_env" });
expect(auth({ env: { FUSION_DAEMON_TOKEN: "fn_daemon" } }))
.toEqual({ kind: "token", token: "fn_daemon" });
});
it("does not claim a foreign port or --no-auth is token-gated", () => {
expect(auth({ port: 5173, readToken: () => "fn_abc" })).toEqual({ kind: "foreign" });
expect(auth({ args: ["dashboard", "--no-auth"], readToken: () => "fn_abc" })).toEqual({ kind: "no-auth" });
});
it("defers to the dashboard banner when no token has been minted yet", () => {
expect(auth()).toEqual({ kind: "token-pending" });
});
it("prints an openable URL for the token case and a warning only where it is true", () => {
const url = "https://neat-fox-tree.trycloudflare.com";
const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n");
expect(tokenLines).toContain("token: fn_abc");
expect(tokenLines).toContain(`${url}/?token=fn_abc`);
expect(tokenLines).not.toContain("unauthenticated");
expect(formatDevTunnelBanner({ url, port: 5173, auth: { kind: "foreign" } }).join("\n"))
.toContain("Fusion adds no auth");
expect(formatDevTunnelBanner({ url, port: 4040, auth: { kind: "no-auth" } }).join("\n"))
.toContain("unauthenticated");
expect(formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token-pending" } }).join("\n"))
.toContain("?token=");
});
});
});

View File

@@ -18,7 +18,7 @@ import {
resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs";
import { createDevSourceWatcher } from "./lib/dev-source-watch.mjs";
import { startDevTunnel } from "./lib/dev-tunnel.mjs";
import { resolveDevTunnelAuth, startDevTunnel } from "./lib/dev-tunnel.mjs";
// Set increased heap size (8GB) to prevent OOM during initial build/start
const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192";
@@ -127,8 +127,18 @@ function runApp(extraArgs) {
*/
if (tunnel && !devTunnel) {
const port = resolveDevTunnelPort(tunnelPort);
/*
FNXC:DevTunnel 2026-08-19-01:18:
Resolved at print time (not at parse time) so the token the dev child mints on a first
authenticated run is already on disk by the time the banner needs it.
*/
const auth = resolveDevTunnelAuth({
port,
dashboardPort: resolveDevTunnelPort(undefined),
args: forwardedArgs,
});
devTunnel = { url: null, stop: () => {} };
void startDevTunnel({ port })
void startDevTunnel({ port, auth })
.then((started) => { devTunnel = started; })
.catch((error) => {
console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`);

View File

@@ -11,11 +11,15 @@ Cloudflare QUICK tunnels are the right tool precisely because a dev server is HT
account, no domain, and no card (the TCP endpoints that SSH would have required need all three).
The trade is that the hostname is random and lives only as long as the process.
NOT a production exposure path: a quick tunnel is unauthenticated, so anyone with the URL reaches the
dev server. It is printed loudly for that reason.
NOT a production exposure path: the quick tunnel itself authenticates nobody. When it points at the
dashboard the dashboard's own bearer token is still the gate (see the banner note below); when it
points at any other port there is no gate at all, and the banner says so.
*/
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
/** Cloudflare prints the assigned hostname once the edge accepts the tunnel. */
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
@@ -36,6 +40,7 @@ export function extractQuickTunnelUrl(text) {
*/
export async function startDevTunnel({
port,
auth,
log = console,
spawnFn = spawn,
timeoutMs = DEFAULT_URL_TIMEOUT_MS,
@@ -99,12 +104,97 @@ export async function startDevTunnel({
await urlPromise;
if (url) {
log.log?.("");
log.log?.(` ┌ dev server tunnel (public, unauthenticated)`);
log.log?.(` │ ${url} → http://localhost:${port}`);
log.log?.(` └ anyone with this URL can reach your dev server`);
log.log?.("");
printDevTunnelBanner({ url, port, auth, log });
}
return { url, stop, child };
}
/*
FNXC:DevTunnel 2026-08-19-01:18:
A tunnel URL alone is not usable when it points at the dashboard: the dashboard is bearer-token
gated, so the recipient lands on an auth wall with no token and the old banner's
"public, unauthenticated" line was actively wrong for the DEFAULT target. The banner now resolves
which of three states the tunnel is actually in and prints the matching thing:
token — dashboard with auth on: print the token AND a token-bearing URL, because handing
someone a URL they cannot open is the whole failure this flag existed to avoid.
The URL embeds the token deliberately (same shape as the local `fn serve` banner);
that is safe for a link you hand to one person and is NOT the `/remote-login`
redirect case, which leaked the daemon token to every recipient of a shared link.
no-auth — `--no-auth` was passed: the tunnel really is open, say so loudly.
foreign — the tunnel points at some other port (a Vite server, say). Fusion has no auth to
lend it, so the unauthenticated warning is correct there and only there.
*/
/** Where the daemon token lives, mirroring core's resolveGlobalDir preference order. */
export function resolveGlobalSettingsFile(home = homedir(), exists = existsSync) {
for (const dir of [join(home, ".fusion"), join(home, ".pi", "fusion"), join(home, ".pi", "kb")]) {
if (exists(dir)) return join(dir, "settings.json");
}
return join(home, ".fusion", "settings.json");
}
function readStoredDaemonToken(settingsFile, read = readFileSync) {
try {
const parsed = JSON.parse(read(settingsFile, "utf8"));
const token = parsed?.daemonToken;
return typeof token === "string" && token.length > 0 ? token : null;
} catch {
return null;
}
}
/**
* Decide what auth (if any) the tunnel's target is behind.
*
* Pure apart from the injected readers so the three states are testable without a real dashboard,
* a real `~/.fusion`, or a real cloudflared.
*/
export function resolveDevTunnelAuth({
port,
dashboardPort,
args = [],
env = process.env,
settingsFile = resolveGlobalSettingsFile(),
readToken = readStoredDaemonToken,
} = {}) {
if (port !== dashboardPort) return { kind: "foreign" };
if (args.includes("--no-auth")) return { kind: "no-auth" };
const token = env.FUSION_DASHBOARD_TOKEN
?? env.FUSION_DAEMON_TOKEN
?? readToken(settingsFile);
// No token on disk yet means the dev child is minting one right now (first authenticated run).
// Predicting it is impossible, so point at the banner that will print it rather than guess.
return token ? { kind: "token", token } : { kind: "token-pending" };
}
export function formatDevTunnelBanner({ url, port, auth }) {
const lines = [` ┌ dev server tunnel`, ` │ ${url} → http://localhost:${port}`];
switch (auth?.kind) {
case "token":
lines.push(` │ token: ${auth.token}`);
lines.push(` │ ready-to-open: ${url}/?token=${encodeURIComponent(auth.token)}`);
lines.push(` └ that link carries the token — share it only with whoever should have access`);
break;
case "token-pending":
lines.push(` └ append the ?token=… from the dashboard's own startup banner to open it`);
break;
case "no-auth":
lines.push(` └ --no-auth is on: anyone with this URL gets your dashboard, unauthenticated`);
break;
default:
lines.push(` └ anyone with this URL can reach that port — Fusion adds no auth to it`);
}
return lines;
}
function printDevTunnelBanner({ url, port, auth, log }) {
log.log?.("");
for (const line of formatDevTunnelBanner({ url, port, auth })) log.log?.(line);
log.log?.("");
}