diff --git a/.changeset/dev-tunnel-prints-dashboard-token.md b/.changeset/dev-tunnel-prints-dashboard-token.md new file mode 100644 index 0000000000..c8d23febe0 --- /dev/null +++ b/.changeset/dev-tunnel-prints-dashboard-token.md @@ -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. diff --git a/docs/contributing.md b/docs/contributing.md index ca5d72d3fe..765adf4f28 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 6cb02d19fa..7cd31a5382 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -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 = {}) => 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="); + }); }); }); diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 1687286701..4c31e6c8b2 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -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)}`); diff --git a/scripts/lib/dev-tunnel.mjs b/scripts/lib/dev-tunnel.mjs index f2e88802ac..5eefd3e3c4 100644 --- a/scripts/lib/dev-tunnel.mjs +++ b/scripts/lib/dev-tunnel.mjs @@ -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?.(""); +}