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:
7
.changeset/dev-tunnel-prints-dashboard-token.md
Normal file
7
.changeset/dev-tunnel-prints-dashboard-token.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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?.("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user