From 9ed98749c0fd402c72fc76e32d8e360ecd99f987 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 06:20:37 +0000 Subject: [PATCH 1/2] feat(cli): enable auth by default and auto-authorize --open via OTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every built-in plugin's standalone CLI now gates the dev server behind devframe's interactive OTP handshake by default (code-server, git, inspect, messages, terminals, data-inspector previously opted out; og/a11y already defaulted on). Each plugin keeps an `auth` option to opt back out for single-user/CI use. To keep that gate from adding friction, `--open` now asks the resolved auth handler to rewrite the launched URL — the interactive handler embeds the current OTP (via buildOtpAuthUrl), so the opened tab authenticates automatically instead of prompting for a code. Adds DevframeAuthHandler.buildOpenUrl (optional) as the seam, wires it through maybeOpenBrowser in the dev adapter, and updates docs/skill guidance + tsnapi snapshots accordingly. Co-authored-by: opencode --- docs/adapters/cac.md | 3 +- docs/guide/devframe-definition.md | 5 +- docs/guide/security.md | 2 +- docs/guide/standalone-cli.md | 3 +- .../src/adapters/__tests__/dev.test.ts | 61 +++++++++++++++++++ packages/devframe/src/adapters/dev.ts | 9 ++- packages/devframe/src/node/auth/handler.ts | 9 +++ .../devframe/src/recipes/interactive-auth.ts | 5 ++ plugins/code-server/src/index.ts | 8 +-- plugins/code-server/src/types.ts | 7 +++ plugins/data-inspector/src/cli.ts | 5 +- plugins/data-inspector/src/index.ts | 10 +-- plugins/git/src/index.ts | 11 +++- plugins/inspect/src/index.ts | 16 ++--- plugins/messages/src/index.ts | 15 ++--- plugins/terminals/src/index.ts | 7 ++- plugins/terminals/src/types.ts | 7 +++ skills/devframe/SKILL.md | 2 +- .../plugin-code-server/types.snapshot.d.ts | 1 + .../@devframes/plugin-git/index.snapshot.d.ts | 1 + .../plugin-terminals/types.snapshot.d.ts | 1 + 21 files changed, 149 insertions(+), 39 deletions(-) diff --git a/docs/adapters/cac.md b/docs/adapters/cac.md index 3a0d22f2..ea4f0ed4 100644 --- a/docs/adapters/cac.md +++ b/docs/adapters/cac.md @@ -75,8 +75,7 @@ defineDevframe({ portRange: [7777, 9000], // passed through to get-port-please random: false, // passed through to get-port-please host: '127.0.0.1', // default host; --host overrides - open: true, // auto-open the browser on dev start - auth: false, // skip the trust handshake (single-user localhost) + open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli.option('--config ', 'Custom config file') .option('--no-files', 'Skip file matching') diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 1cdd985d..9c87de34 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -185,8 +185,7 @@ defineDevframe({ portRange: [9876, 10000], // forwarded to get-port-please random: false, // forwarded to get-port-please host: 'localhost', // default host; --host overrides - open: true, // auto-open the browser on dev start - auth: false, // skip the trust handshake (single-user localhost) + open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli .option('--my-flag ', 'Tool-specific flag') @@ -208,7 +207,7 @@ defineDevframe({ | `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. | | `random` | `boolean` | Prefer a random open port. | | `host` | `string` | Default bind host. | -| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. | +| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. When `auth` is on, the opened URL embeds the current OTP so the tab authenticates automatically. | | `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. | | `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCac`'s `configureCli` option so the final tool author always has the last word. | diff --git a/docs/guide/security.md b/docs/guide/security.md index 6797b982..71b991ca 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -79,7 +79,7 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod ### Magic-link authentication -To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. Build it from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): +To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. The standalone CLI (`createCac` / `createDevServer`) does this automatically for `--open`: when the server is auth-gated, the browser it launches already carries the current code, so the tab lands authenticated with no prompt at all. Build the link yourself from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): ``` Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456 diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md index f641745e..eb73c65e 100644 --- a/docs/guide/standalone-cli.md +++ b/docs/guide/standalone-cli.md @@ -43,8 +43,7 @@ const devframe = defineDevframe({ distDir, port: 7777, portRange: [7777, 9000], - open: true, - auth: false, // single-user localhost — skip the trust handshake + open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated configure(cli) { cli .option('--config ', 'Config file path') diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 39242b61..a471a346 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { open } from 'devframe/utils/open' import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' @@ -11,6 +12,10 @@ import { getTempAuthCode } from '../../node/auth/state' import { defineDevframe } from '../../types/devframe' import { createDevServer, resolveDevServerPort } from '../dev' +// `--open` is exercised directly (asserting the URL handed to the OS opener) +// rather than actually launching a browser in CI. +vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) })) + function connectWsClient(host: string, port: number, authToken?: string) { return createRpcClient( {} as DevframeRpcClientFunctions, @@ -323,6 +328,62 @@ describe('adapters/dev', () => { } }) + it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-open', + name: 'Auth Open', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19415, host }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const mockedOpen = vi.mocked(open) + mockedOpen.mockClear() + const code = getTempAuthCode() + const handle = await createDevServer(devframe, { host, port, openBrowser: true }) + + try { + expect(mockedOpen).toHaveBeenCalledTimes(1) + const [target] = mockedOpen.mock.calls[0] + expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`) + } + finally { + spy.mockRestore() + await handle.close() + } + }) + + it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-open-off', + name: 'Auth Open Off', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + cli: { auth: false }, + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19416, host }) + const mockedOpen = vi.mocked(open) + mockedOpen.mockClear() + const handle = await createDevServer(devframe, { host, port, openBrowser: true }) + + try { + expect(mockedOpen).toHaveBeenCalledTimes(1) + const [target] = mockedOpen.mock.calls[0] + expect(target).toBe(`http://localhost:${port}/`) + } + finally { + await handle.close() + } + }) + it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => { const devframe = defineDevframe({ id: 'devframe-auth-off', diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 454422ab..706d04b5 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -241,7 +241,7 @@ export async function createDevServer( // so the code is on screen by the time a browser lands on the page. authHandler?.printBanner() await options.onReady?.(info) - await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) + await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler) }, }) @@ -303,6 +303,7 @@ async function maybeOpenBrowser( flags: Record, origin: string, override: boolean | string | undefined, + authHandler: DevframeAuthHandler | undefined, ): Promise { const flagsOpen = flags.open as boolean | string | undefined const cliOpen = def.cli?.open @@ -314,8 +315,12 @@ async function maybeOpenBrowser( const target = typeof resolved === 'string' ? withBase(resolved, origin) : origin + // When the server is auth-gated, let the handler embed a one-time + // credential (e.g. the OTP query param) in the opened URL so the tab + // lands already authorized instead of prompting the user. + const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target try { - await open(target) + await open(authorizedTarget) } catch { // Failing to launch a browser shouldn't break the dev server. diff --git a/packages/devframe/src/node/auth/handler.ts b/packages/devframe/src/node/auth/handler.ts index 5b4650ab..83057788 100644 --- a/packages/devframe/src/node/auth/handler.ts +++ b/packages/devframe/src/node/auth/handler.ts @@ -40,4 +40,13 @@ export interface DevframeAuthHandler { * call repeatedly; it only prints once per code. */ printBanner: () => void + /** + * Rewrite a URL the CLI is about to open in the browser (`--open`) so the + * tab lands already authenticated — e.g. appending the current OTP as a + * query param. Called with the fully-resolved target URL; return it + * unchanged to opt out. Optional: a handler that doesn't need this (e.g. + * one gated by a pre-shared bearer token instead of an interactive code) + * can omit it and the URL opens as-is. + */ + buildOpenUrl?: (url: string) => string } diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 74d2f88c..a52ea783 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -166,10 +166,15 @@ export function createInteractiveAuth( verifyAuthToken(token, session, storage) } + function buildOpenUrl(url: string): string { + return buildOtpAuthUrl(url) + } + return { rpcFunctions: [anonymousAuth, anonymousAuthExchange, revoke], authorize, onConnect: onConnect as DevframeAuthHandler['onConnect'], printBanner, + buildOpenUrl, } } diff --git a/plugins/code-server/src/index.ts b/plugins/code-server/src/index.ts index f7f54020..e371fd1b 100644 --- a/plugins/code-server/src/index.ts +++ b/plugins/code-server/src/index.ts @@ -55,10 +55,10 @@ export function createCodeServerDevframe(options: CodeServerOptions = {}): Devfr portRange: options.portRange, random: options.random, distDir: existsSync(resolvedDist) ? resolvedDist : undefined, - // Single-user localhost tool: auto-trust the connection so the launcher's - // shared-state subscription and the auth handoff work without a manual - // round-trip. Hosted adapters supply their own auth layer. - auth: false, + // Gate the standalone launcher by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters supply their own auth layer and ignore this. + auth: options.auth ?? true, }, spa: { loader: 'none' }, async setup(ctx) { diff --git a/plugins/code-server/src/types.ts b/plugins/code-server/src/types.ts index 43c98dbb..8d42e920 100644 --- a/plugins/code-server/src/types.ts +++ b/plugins/code-server/src/types.ts @@ -174,6 +174,13 @@ export interface CodeServerOptions { portRange?: [number, number] /** Prefer a random port for the launcher SPA. */ random?: boolean + /** + * Require the trust handshake on the standalone launcher server. Enabled + * by default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } /** Options for `mode: 'tunnel'`. */ diff --git a/plugins/data-inspector/src/cli.ts b/plugins/data-inspector/src/cli.ts index 5d43e02c..3cfc572e 100644 --- a/plugins/data-inspector/src/cli.ts +++ b/plugins/data-inspector/src/cli.ts @@ -52,7 +52,10 @@ export function createDataInspectorCli() { .option('--open', 'Open the browser on start') .option('--no-open', 'Do not open the browser') .option('--no-example', 'Skip the built-in example source') - .action(async (files: string[], flags: { port?: number, host: string, open?: boolean, example?: boolean }) => { + // Standalone auth is on by default; `--no-auth` opts a one-off run out + // of the interactive OTP gate (see `createCac`'s equivalent flag). + .option('--no-auth', 'Disable the interactive authentication gate') + .action(async (files: string[], flags: { port?: number, host: string, open?: boolean, example?: boolean, auth?: boolean }) => { for (const file of files) registerDataSource(createFileDataSource(file)) const def = createDataInspectorDevframe({ exampleSource: flags.example }) diff --git a/plugins/data-inspector/src/index.ts b/plugins/data-inspector/src/index.ts index cda661bd..28050ea1 100644 --- a/plugins/data-inspector/src/index.ts +++ b/plugins/data-inspector/src/index.ts @@ -31,9 +31,11 @@ export interface DataInspectorDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) for the single-user localhost CLI. The in-process - * agent (`@devframes/plugin-data-inspector/inject`) defaults to `true`. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. The in-process + * agent (`@devframes/plugin-data-inspector/inject`) uses its own + * pre-shared-token scheme and is unaffected by this option. */ auth?: boolean /** @@ -72,7 +74,7 @@ export function createDataInspectorDevframe(options: DataInspectorDevframeOption command: 'data-inspector', port: options.port ?? DEFAULT_PORT, distDir: existsSync(distDir) ? distDir : undefined, - auth: options.auth ?? false, + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { category: '~builtin' }, diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 2dfd8059..5628f97c 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -37,6 +37,13 @@ export interface GitDevframeOptions { * default; the standalone CLI also accepts a `--write` flag. */ write?: boolean + /** + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } /** @@ -61,7 +68,9 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef command: 'devframe-git', port: options.port ?? 9710, distDir, - auth: false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + auth: options.auth ?? true, configure(cli) { cli.option('--write', 'Enable staging, unstaging, and committing from the UI') }, diff --git a/plugins/inspect/src/index.ts b/plugins/inspect/src/index.ts index dbc79753..e9ac000f 100644 --- a/plugins/inspect/src/index.ts +++ b/plugins/inspect/src/index.ts @@ -28,9 +28,10 @@ export interface InspectDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) since the inspector is a single-user localhost - * tool. Hosted adapters manage their own auth. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. */ auth?: boolean } @@ -58,11 +59,10 @@ export function createInspectDevframe(options: InspectDevframeOptions = {}): Dev command: id, port: options.port ?? 9012, distDir: existsSync(distDir) ? distDir : undefined, - // A single-user localhost inspector: skip the trust handshake so - // the SPA's shared-state subscription initializes without a manual - // auth round-trip. Hosted adapters (Vite/hub) supply their own - // auth layer and ignore this. - auth: options.auth ?? false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters (Vite/hub) supply their own auth layer and ignore this. + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts index e31bb21d..ca8a5768 100644 --- a/plugins/messages/src/index.ts +++ b/plugins/messages/src/index.ts @@ -26,9 +26,10 @@ export interface MessagesDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) since the panel is a single-user localhost tool. - * Hosted adapters manage their own auth. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. */ auth?: boolean } @@ -58,10 +59,10 @@ export function createMessagesDevframe(options: MessagesDevframeOptions = {}): D command: id, port: options.port ?? DEFAULT_PORT, distDir: existsSync(distDir) ? distDir : undefined, - // A single-user localhost panel: skip the trust handshake so the SPA - // initializes without a manual auth round-trip. Hosted adapters - // (Vite/hub) supply their own auth layer and ignore this. - auth: options.auth ?? false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters (Vite/hub) supply their own auth layer and ignore this. + auth: options.auth ?? true, }, dock: { category: '~builtin', diff --git a/plugins/terminals/src/index.ts b/plugins/terminals/src/index.ts index ed318d17..578777ed 100644 --- a/plugins/terminals/src/index.ts +++ b/plugins/terminals/src/index.ts @@ -56,9 +56,10 @@ export function createTerminalsDevframe(options: TerminalsOptions = {}): Devfram command: options.command ?? 'devframe-terminals', port: options.port ?? DEFAULT_PORT, distDir, - // Single-user localhost tool: auto-trust the connection so streaming - // and shared-state sync work without an auth round-trip. - auth: false, + // Gate the standalone server by default — shell access is sensitive. + // `maybeOpenBrowser` folds the current OTP into the `--open` URL so + // the tab lands already trusted. + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { diff --git a/plugins/terminals/src/types.ts b/plugins/terminals/src/types.ts index 47a230c7..36c07bba 100644 --- a/plugins/terminals/src/types.ts +++ b/plugins/terminals/src/types.ts @@ -137,4 +137,11 @@ export interface TerminalsOptions { command?: string /** Preferred dev-server port. */ port?: number + /** + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 1dfccebc..60d1b2d9 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -569,7 +569,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI. - **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. - **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. -- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. +- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts index 0d202e4c..8930d7a2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts @@ -42,6 +42,7 @@ export interface CodeServerOptions { port?: number; portRange?: [number, number]; random?: boolean; + auth?: boolean; } export interface CodeServerServerInfo { status: CodeServerStatus; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts index 4249f94d..07560110 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts @@ -81,6 +81,7 @@ export interface GitDevframeOptions { distDir?: string; port?: number; write?: boolean; + auth?: boolean; } export interface GitDiff { isRepo: boolean; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts index 3d1af5e0..7808c701 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts @@ -59,6 +59,7 @@ export interface TerminalsOptions { distDir?: string; command?: string; port?: number; + auth?: boolean; } export interface TerminalsSharedState { sessions: TerminalSessionInfo[]; From c53bbc8826c5bef094a48cb4c898390fc216170c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 06:48:26 +0000 Subject: [PATCH 2/2] fix(client): gate outbound calls behind the auth bootstrap handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectDevframe()/getDevframeRpcClient() kicks off the auth bootstrap (stored token, then the URL's magic-link OTP, then a native-prompt fallback) without waiting for it, so it can return the client right away. A caller's very first RPC calls — e.g. a component's onMount, fired the instant connectDevframe() resolves — raced that still in-flight sequence: the socket was already open, so the transport sent them straight through, and the server rejected them with DF0036 because trust hadn't landed yet, even though the exact same call would succeed a moment later once the handshake completed. rpc.call/callOptional/callEvent now hold anything issued before the first bootstrap attempt settles and release it (or fail fast, if the handshake ultimately failed) once it does. This lives once in devframe's client RPC core, so every plugin's standalone/hosted SPA gets it automatically — no plugin-level changes needed. Co-authored-by: opencode --- docs/guide/client.md | 2 + docs/guide/security.md | 2 + .../devframe/src/client/rpc-auth-gate.test.ts | 140 ++++++++++++++++++ packages/devframe/src/client/rpc.ts | 39 ++++- 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 packages/devframe/src/client/rpc-auth-gate.test.ts diff --git a/docs/guide/client.md b/docs/guide/client.md index ceea5605..f0ee6a28 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -80,6 +80,8 @@ if (!trusted) { } ``` +`connectDevframe()` kicks off that same handshake (stored token, then a magic-link OTP on the page URL, then — top-level pages only — a native prompt) the moment it resolves, without making the caller wait for it. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` know about that in-flight handshake internally and hold anything issued before it settles, so application code can call a trusted method right away — e.g. in a component's `onMount` — without an explicit `ensureTrusted()` guard just to avoid a race. Reach for `ensureTrusted()` when you want to reflect the pending state in the UI (a spinner, a "waiting for auth" banner), not to make calls safe. + ### Authenticating with a one-time code A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to `requestTrustWithCode` to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code: diff --git a/docs/guide/security.md b/docs/guide/security.md index 71b991ca..6fa44c37 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -24,6 +24,8 @@ Exactly one rule decides what an untrusted connection may call: **a method is re `startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036). +On the client, `connectDevframe()` kicks off the handshake below without waiting for it, so a naive client could otherwise race it — sending a trusted call over the freshly-opened socket before the server has had a chance to answer `anonymous:devframe:auth`, hitting this exact gate. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued while that first handshake is still in flight and release it once the handshake settles, so application code never has to special-case this window itself. + ## Authentication flow Authentication exchanges a short code for a long-lived token. A node mints and owns the token; the browser only ever sends the short code, and only over the open socket. diff --git a/packages/devframe/src/client/rpc-auth-gate.test.ts b/packages/devframe/src/client/rpc-auth-gate.test.ts new file mode 100644 index 00000000..34cb3d51 --- /dev/null +++ b/packages/devframe/src/client/rpc-auth-gate.test.ts @@ -0,0 +1,140 @@ +import type { ConnectionMeta } from 'devframe/types' +import type { DevframeRpcClientMode } from './rpc' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// `getDevframeRpcClient` kicks off the auth bootstrap (`requestTrust`, then +// the URL OTP, then a native-prompt fallback) without awaiting it, so it can +// return the client immediately. The regression this guards is a caller's +// very first `rpc.call(...)` — issued the moment `connectDevframe()` resolves +// — racing that still-in-flight bootstrap: without a gate, the transport +// sends the call over an already-open-but-not-yet-trusted socket and the +// server rejects it with DF0036, even though the exact same call succeeds a +// moment later once the handshake lands. +// +// Exercising this through a real WebSocket would mean hand-rolling birpc's +// wire protocol, so instead this mocks `./rpc-ws` with a controllable fake +// mode — a `requestTrust()` the test resolves on its own schedule, and a +// `call` spy whose invocation timing is exactly what's under test. +const fakeMode = vi.hoisted(() => { + const requestTrustGate = { resolve: undefined as ((value: boolean) => void) | undefined } + const call = vi.fn(async () => 'ok') + const callOptional = vi.fn(async () => 'ok') + const callEvent = vi.fn(async () => {}) + return { requestTrustGate, call, callOptional, callEvent } +}) + +vi.mock('./rpc-ws', () => ({ + createWsRpcClientMode: vi.fn((): DevframeRpcClientMode => ({ + isTrusted: false, + status: 'connecting', + connectionError: null, + ensureTrusted: async () => true, + requestTrust: () => new Promise((resolve) => { + fakeMode.requestTrustGate.resolve = resolve + }), + requestTrustWithToken: async () => true, + requestTrustWithCode: async () => null, + call: fakeMode.call as DevframeRpcClientMode['call'], + callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'], + callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'], + })), +})) + +class FakeBroadcastChannel { + onmessage: ((e: any) => void) | null = null + postMessage(): void {} + close(): void {} +} + +const connectionMeta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } + +describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => { + beforeEach(() => { + fakeMode.requestTrustGate.resolve = undefined + fakeMode.call.mockClear() + fakeMode.callOptional.mockClear() + fakeMode.callEvent.mockClear() + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) + vi.stubGlobal('navigator', { userAgent: 'test' }) + vi.stubGlobal('location', { + protocol: 'http:', + host: 'localhost:5173', + hostname: 'localhost', + href: 'http://localhost:5173/index.html', + origin: 'http://localhost:5173', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('holds `call` until the in-flight bootstrap settles, instead of sending it early', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + // Fired the instant the client is available — mirrors a component's + // `onMount` calling a trusted method right after `connectDevframe()`. + const pending = rpc.call('test:probe' as any) + await Promise.resolve() + await Promise.resolve() + // The bootstrap's `requestTrust()` hasn't resolved yet — the call must + // not have reached the (mocked) transport. + expect(fakeMode.call).not.toHaveBeenCalled() + + // The handshake lands. + fakeMode.requestTrustGate.resolve?.(true) + + await expect(pending).resolves.toBe('ok') + expect(fakeMode.call).toHaveBeenCalledTimes(1) + expect(fakeMode.call).toHaveBeenCalledWith('test:probe') + }) + + it('holds `callOptional` and `callEvent` the same way', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + const pendingOptional = rpc.callOptional('test:optional' as any) + const pendingEvent = rpc.callEvent('test:event' as any) + await Promise.resolve() + await Promise.resolve() + expect(fakeMode.callOptional).not.toHaveBeenCalled() + expect(fakeMode.callEvent).not.toHaveBeenCalled() + + fakeMode.requestTrustGate.resolve?.(true) + + await expect(pendingOptional).resolves.toBe('ok') + await pendingEvent + expect(fakeMode.callOptional).toHaveBeenCalledTimes(1) + expect(fakeMode.callEvent).toHaveBeenCalledTimes(1) + }) + + it('stops gating once the first bootstrap attempt has settled', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + fakeMode.requestTrustGate.resolve?.(true) + // Let the bootstrap's own promise chain fully settle before the next + // call — a few microtask ticks cover `await mode.requestTrust()` + // resuming, `bootstrapAuth()` returning, and its `.then()` flipping + // `bootstrapAuthSettled`. + for (let i = 0; i < 5; i++) + await Promise.resolve() + + await rpc.call('test:probe' as any) + // Sent straight through — no more waiting once bootstrap is over. + expect(fakeMode.call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 5b1be907..0704c60b 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -408,6 +408,30 @@ export async function getDevframeRpcClient( } catch {} + // Gate outbound calls behind the auth bootstrap kicked off below (stored + // auth token, then the URL's magic-link OTP, then — standalone/top-level + // only — a native prompt). Without this, a caller's very first RPC calls + // (fired the moment `connectDevframe()` resolves — e.g. a component's + // `onMount`) race that still-in-flight sequence: the socket is open, so + // the transport happily sends them, and the server rejects them with + // DF0036 because trust hasn't landed yet — even though the exact same call + // would succeed a moment later. `bootstrapAuthPromise` is assigned once + // `bootstrapAuth()` is kicked off further down; these closures read the + // variable at call time, so the gate is live even though it's declared + // before that assignment happens. Once the first bootstrap attempt has + // settled (trusted, refused, or given up), `bootstrapAuthSettled` flips + // for good and every later call skips the gate entirely — this only ever + // holds the first wave of calls. + let bootstrapAuthPromise: Promise | undefined + let bootstrapAuthSettled = false + function gateOnBootstrapAuth any>(fn: F): F { + return ((...args: any[]) => { + if (bootstrapAuthSettled || !bootstrapAuthPromise) + return fn(...args) + return bootstrapAuthPromise.then(() => fn(...args)) + }) as F + } + const rpc: DevframeRpcClient = { events, get isTrusted() { @@ -440,9 +464,9 @@ export async function getDevframeRpcClient( catch {} return true }, - call: mode.call, - callEvent: mode.callEvent, - callOptional: mode.callOptional, + call: gateOnBootstrapAuth(mode.call), + callEvent: gateOnBootstrapAuth(mode.callEvent), + callOptional: gateOnBootstrapAuth(mode.callOptional), client: clientRpc, sharedState: undefined!, streaming: undefined!, @@ -522,7 +546,14 @@ export async function getDevframeRpcClient( return await runSimpleAuthPrompt() } - void bootstrapAuth() + // Always resolves (never rejects) regardless of `bootstrapAuth`'s outcome — + // the gate only cares that the first attempt is *over*, not whether it + // succeeded; a rejection here must never leak into unrelated calls waiting + // on `bootstrapAuthPromise`. + bootstrapAuthPromise = bootstrapAuth().then( + () => { bootstrapAuthSettled = true }, + () => { bootstrapAuthSettled = true }, + ) // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange).