From 8b6dc681ab7ee9e47029dd3ac421376ab689c4fe Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 17 Jul 2026 03:27:59 +0000 Subject: [PATCH 1/3] feat(auth): standalone simple-auth fallback with headless prompt + hub token propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give devframe a built-in authentication fallback so a tool the author has not explicitly configured is gated out of the box, while a hub can keep authenticating centrally. - Standalone dev server gates by default: an unset (or `true`) `cli.auth` auto-wires the interactive OTP handler and prints its code banner; `false` (or the new `--no-auth` flag) opts out; a handler object passes through. - Client gains `simpleAuth` (default on): on refusal it tries the magic-link OTP, then falls back to a native `window.prompt()` for the code, re-prompting until success or cancel. It only prompts on a top-level, unframed page. - A framed plugin never prompts; it waits for a hub-propagated token. Hubs authenticate once at the top level and hand the token down — via the client host context same-origin, or a new `ConnectionMeta.authToken` for cross-origin iframes that cannot read the hub's localStorage. Created with the help of an agent. --- .../src/adapters/__tests__/dev.test.ts | 112 +++++++++++++++++- packages/devframe/src/adapters/cac.ts | 4 + packages/devframe/src/adapters/dev.ts | 28 ++++- packages/devframe/src/client/rpc.test.ts | 28 +++++ packages/devframe/src/client/rpc.ts | 84 +++++++++++-- packages/devframe/src/types/context.ts | 15 +++ packages/devframe/src/types/devframe.ts | 24 +++- .../tsnapi/devframe/client.snapshot.d.ts | 1 + 8 files changed, 276 insertions(+), 20 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index f2b8f988..39242b61 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -1,12 +1,25 @@ +import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types' import { mkdtempSync, writeFileSync } from 'node:fs' 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 { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' +import { getTempAuthCode } from '../../node/auth/state' import { defineDevframe } from '../../types/devframe' import { createDevServer, resolveDevServerPort } from '../dev' +function connectWsClient(host: string, port: number, authToken?: string) { + return createRpcClient( + {} as DevframeRpcClientFunctions, + { channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) }, + ) +} + +const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' } + function makeTmpDist(): string { const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-')) writeFileSync(join(dir, 'index.html'), 'test', 'utf-8') @@ -271,6 +284,103 @@ describe('adapters/dev', () => { } }) + it('gates by default: an unset `auth` auto-wires the interactive OTP handler', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-default', + name: 'Auth Default', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19410, host }) + // No `banner` override is exposed through the adapter, so silence the + // default stdout banner for the test run. + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const client = connectWsClient(host, port) + // Untrusted: only `anonymous:` methods are reachable; the probe rejects. + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: false }) + await expect(client.$call('test:probe' as any)).rejects.toThrow() + + // The interactive exchange method is wired — the printed code trusts. + const code = getTempAuthCode() + const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null } + expect(exchange.authToken).toBeTruthy() + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + spy.mockRestore() + 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', + name: 'Auth Off', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + cli: { auth: false }, + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19420, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false }) + + try { + const client = connectWsClient(host, port) + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: true }) + // Ungated: the probe resolves without any code exchange. + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await handle.close() + } + }) + + it('the `--no-auth` flag (flags.auth === false) opts out of the gate', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-flag', + name: 'Auth Flag', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19430, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false, flags: { auth: false } }) + + try { + const client = connectWsClient(host, port) + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: true }) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await handle.close() + } + }) + it('resolveDevServerPort honors def.cli.port as the preferred default', async () => { const preferred = await getPort({ port: 19500, host: '127.0.0.1' }) const devframe = defineDevframe({ diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index 804cab35..c51bc05b 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -66,6 +66,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) .option('--host ', 'Host to bind to', { default: defaultHost }) .option('--open', 'Open the browser on start') .option('--no-open', 'Do not open the browser') + // Standalone auth is on by default; `--no-auth` opts a one-off run out of + // the interactive OTP gate. The `true` default CAC injects is harmless — + // the dev server only acts on an explicit `auth: false`. + .option('--no-auth', 'Disable the interactive authentication gate') // Only `--mcp` is declared: CAC's `--no-*` auto-negation would inject a // `true` default, silently enabling MCP. Declaring just `--mcp` yields the // opt-in tri-state — absent → `undefined` (falls through to `cli.mcp`), diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 7ede0194..454422ab 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,3 +1,4 @@ +import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' @@ -14,6 +15,7 @@ import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' import { normalizeHttpServerUrl } from '../node/utils' +import { createInteractiveAuth } from '../recipes/interactive-auth' import { normalizeBasePath, resolveBasePath } from './_shared' const DEFAULT_PORT = 9999 @@ -205,6 +207,27 @@ export async function createDevServer( if (distDir) mountStaticHandler(app, basePath, resolve(distDir)) + // Resolve authentication. The standalone dev server gates by default: when + // the author leaves `auth` unset (or `true`), auto-wire devframe's + // interactive OTP handler and print its code + magic-link banner once the + // server is listening (a gate is useless without surfacing the code). A + // `false` (including the `--no-auth` flag) opts out; a handler object is + // passed straight through to `startHttpAndWs`. + const authOption = flags.auth === false ? false : def.cli?.auth + let authHandler: DevframeAuthHandler | undefined + let resolvedAuth: boolean | DevframeAuthHandler + if (authOption === false) { + resolvedAuth = false + } + else if (typeof authOption === 'object') { + authHandler = authOption + resolvedAuth = authOption + } + else { + authHandler = createInteractiveAuth(ctx) + resolvedAuth = authHandler + } + const started = await startHttpAndWs({ context: ctx, host, @@ -212,8 +235,11 @@ export async function createDevServer( app, path: bindPath, wsPort, - auth: def.cli?.auth, + auth: resolvedAuth, onReady: async (info) => { + // Print the auth banner before the caller's own onReady / browser open + // 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) }, diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts index 7d9b3cf7..118c97bc 100644 --- a/packages/devframe/src/client/rpc.test.ts +++ b/packages/devframe/src/client/rpc.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDevframeRpcClient } from './rpc' const CONNECTION_META_KEY = '__DEVFRAME_CONNECTION_META__' +const CONNECTION_AUTH_TOKEN_KEY = '__DEVFRAME_CONNECTION_AUTH_TOKEN__' // Minimal fake WebSocket: records the URL it was dialed with (all this suite // needs) and never opens, so the trust handshake stays pending. @@ -43,12 +44,14 @@ describe('getDevframeRpcClient — connection meta base', () => { origin: 'http://localhost:5173', }) delete (globalThis as any)[CONNECTION_META_KEY] + delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() delete (globalThis as any)[CONNECTION_META_KEY] + delete (globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] }) it('publishes the meta annotated with the absolute base it resolved from', async () => { @@ -83,6 +86,31 @@ describe('getDevframeRpcClient — connection meta base', () => { expect(rpc.connectionMeta.baseUrl).toBe('http://localhost:5173/__devtools/__connection.json') }) + it('uses a token embedded in the (hub-served) connection meta as the bearer token', async () => { + // A hub bakes the token into the per-frame meta so a cross-origin frame — + // which can't read the hub's localStorage — is pre-authorized on connect. + await getDevframeRpcClient({ + baseURL: '/__foo/', + otpParam: false, + simpleAuth: false, + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' }, + }) + + expect(lastWsUrl()).toContain('devframe_auth_token=hub-token') + }) + + it('prefers an explicit authToken option over the connection-meta token', async () => { + await getDevframeRpcClient({ + baseURL: '/__foo/', + otpParam: false, + simpleAuth: false, + authToken: 'explicit-token', + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' }, authToken: 'hub-token' }, + }) + + expect(lastWsUrl()).toContain('devframe_auth_token=explicit-token') + }) + it('ignores a window baseUrl when connection meta is passed explicitly', async () => { ;(globalThis as any)[CONNECTION_META_KEY] = { backend: 'websocket', diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 95742e68..d77cd597 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -65,6 +65,18 @@ export interface DevframeRpcClientOptions { * own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`. */ otpParam?: string | false + /** + * Fall back to a native browser `prompt()` for the one-time authentication + * code when the server refuses trust and no other credential succeeds (a + * stored token, an injected token, or a magic-link OTP). The prompt fires + * only on a **top-level, unframed** page — a framed plugin (e.g. mounted in + * a hub dock) never prompts, since a hub pre-authorizes it and browsers + * block `prompt()` in cross-origin frames anyway. + * + * Set `false` to drive your own auth UI (a hub sets this on the plugin + * connections it manages, alongside supplying the token). Default: `true`. + */ + simpleAuth?: boolean wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -199,9 +211,13 @@ export interface DevframeRpcClientMode { callOptional: DevframeRpcClient['callOptional'] } -function getStoredAuthToken(userAuthToken?: string): string | undefined { +function getStoredAuthToken(userAuthToken?: string, metaAuthToken?: string): string | undefined { const getters = [ + // Explicit option wins, then a token baked into the (hub-served) meta — + // the cross-origin channel a framed plugin relies on since it can't read + // the hub's `localStorage` — then this origin's own stored token. () => userAuthToken, + () => metaAuthToken, () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined, () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], @@ -317,7 +333,7 @@ export async function getDevframeRpcClient( const context: DevframeRpcContext = { rpc: undefined!, } - const authToken = getStoredAuthToken(options.authToken) + const authToken = getStoredAuthToken(options.authToken, connectionMeta.authToken) // Persist a resolved token so one supplied out-of-band — e.g. a host that // bootstraps trust by passing `authToken` (read from its own page URL query) // — survives reconnects. The token is still sent to the server via the WS @@ -450,16 +466,60 @@ export async function getDevframeRpcClient( // @ts-expect-error assign to readonly property context.rpc = rpc - void mode.requestTrust() - - // Magic-link authentication: if the page URL carries a one-time code, exchange - // it and strip it from the URL. The code is single-use and short-lived; the - // resulting bearer token is persisted (never written back to the URL). - // Integrations that drive their own auth UI opt out with `otpParam: false` - // and call `authenticateWithUrlOtp` / `consumeOtpFromUrl` directly. - const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM - if (otpParam) - void authenticateWithUrlOtp(rpc, { param: otpParam }) + + // Whether this document is the top-level, unframed page. Only there can a + // native `prompt()` actually be shown — a framed plugin (hub dock) instead + // waits for a hub-injected/broadcast token to arrive. Accessing + // `window.top` cross-origin throws, which itself means we're framed. + function isTopLevelUnframed(): boolean { + try { + return typeof window !== 'undefined' && window.self === window.top + } + catch { + return false + } + } + + // Last-resort standalone fallback: ask for the one-time code via the + // browser's native `prompt()` (zero UI, so devframe stays headless) and + // re-prompt on a wrong/expired code until the exchange succeeds or the user + // cancels. Cancelling leaves the connection `unauthorized` without nagging. + async function runSimpleAuthPrompt(): Promise { + if (options.simpleAuth === false || !isTopLevelUnframed()) + return + if (typeof globalThis.prompt !== 'function') + return + while (!rpc.isTrusted) { + // eslint-disable-next-line no-alert -- native prompt() is intentional: zero UI keeps devframe headless. + const code = globalThis.prompt('devframe: enter the authentication code shown in your terminal') + // Cancel → stop; leave status `unauthorized`. + if (code == null) + return + const trimmed = code.trim() + if (!trimmed) + continue + if (await rpc.requestTrustWithCode(trimmed)) + return + } + } + + // Drive trust in order: the connect-time handshake (stored/injected token) + // first, then the magic-link OTP (silent — a one-time code on the page URL, + // single-use and short-lived, stripped from the URL and never re-persisted), + // then the native-prompt fallback. Integrations that drive their own auth UI + // opt out of the URL read with `otpParam: false` and of the prompt with + // `simpleAuth: false`. + async function bootstrapAuth(): Promise { + const trusted = await mode.requestTrust() + const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM + // Always consume the URL OTP (so it's stripped) even once trusted; it only + // exchanges when a code is present and we're not yet trusted. + const viaOtp = otpParam ? await authenticateWithUrlOtp(rpc, { param: otpParam }) : false + if (trusted || viaOtp || rpc.isTrusted) + return + await runSimpleAuthPrompt() + } + void bootstrapAuth() // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index a7a2fd0d..1d693e2b 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -129,4 +129,19 @@ export interface ConnectionMeta { * endpoint rather than resolving the path against its own mount. */ baseUrl?: string + /** + * A pre-issued bearer token embedded in the meta so a client trusts the + * server on connect without any prompt or `localStorage` lookup. Only a + * **hub** serving a **per-frame** connection meta populates this — it + * authenticates once at the top level and bakes the resulting token into + * the meta each plugin iframe fetches, so a cross-origin frame (which + * cannot read the hub's `localStorage`) is still pre-authorized. The + * standalone `__connection.json` never carries a token. + * + * > [!WARNING] + * > A token in a fetchable JSON is only as protected as the URL serving + * > it. Emit it exclusively from hub-controlled, per-frame meta — never + * > from a publicly reachable static `__connection.json`. + */ + authToken?: string } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index b6a04208..679c7386 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -1,5 +1,6 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' +import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' export type DevframeRuntime = 'cli' | 'build' | 'spa' | 'vite' | 'embedded' @@ -108,14 +109,25 @@ export interface DevframeCliOptions { */ open?: boolean | string /** - * Skip the RPC trust handshake. Set to `false` for trusted - * single-user localhost tools. Default `true`. + * Authentication for the standalone dev server. * - * Forwarded to `startHttpAndWs` as a no-op placeholder until devframe - * ships its own auth layer; `@vitejs/devtools` honors the equivalent - * `devtools.clientAuth` today. + * - `undefined` / `true` (default) — the standalone adapters (`cli` / + * `spa` / served `build`) auto-wire devframe's interactive OTP auth + * (`createInteractiveAuth`): an untrusted client can only reach + * `anonymous:` methods until it exchanges the printed one-time code. + * The adapter prints the code + magic-link banner once the server is + * listening. + * - `false` — no gate, for trusted single-user localhost tools where an + * auth round-trip only gets in the way (the built-in plugins set this). + * The `--no-auth` CLI flag maps here for one-off runs. + * - A {@link DevframeAuthHandler} — a custom handler (e.g. a tuned + * `createInteractiveAuth`, or an entirely different scheme) passed + * straight through to `startHttpAndWs`. + * + * Hosted adapters (`vite`, `embedded`) ignore this and defer to the host's + * auth; `@vitejs/devtools` honors the equivalent `devtools.clientAuth`. */ - auth?: boolean + auth?: boolean | DevframeAuthHandler /** * Expose a route-based MCP server alongside the dev server, speaking the * MCP Streamable-HTTP transport at `/__mcp` (relative to the base path). diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 72ce9efb..609681ef 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -41,6 +41,7 @@ export interface DevframeRpcClientOptions { baseURL?: string | string[]; authToken?: string; otpParam?: string | false; + simpleAuth?: boolean; wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial; From 28a0593779e1ab4f06b6f8e8f8ec1edfe70e0987 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 17 Jul 2026 03:34:07 +0000 Subject: [PATCH 2/3] docs(auth): use the @default jsdoc tag for the auth option defaults Convert the prose "Default: x" notes on the auth-related options introduced in this branch (`simpleAuth`, `otpParam`, `cli.auth`) to the `@default` tag, matching the convention used elsewhere in the definition types. --- packages/devframe/src/client/rpc.ts | 8 ++++++-- packages/devframe/src/types/devframe.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index d77cd597..d22dc2ce 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -62,7 +62,9 @@ export interface DevframeRpcClientOptions { * (OTP) for "magic link" auth (e.g. a link the dev server prints). When * present, the client exchanges the code for a token and removes the parameter * from the URL. Set `false` to disable — e.g. integrations that drive their - * own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`. + * own authentication via `authenticateWithUrlOtp`. + * + * @default 'devframe_otp' */ otpParam?: string | false /** @@ -74,7 +76,9 @@ export interface DevframeRpcClientOptions { * block `prompt()` in cross-origin frames anyway. * * Set `false` to drive your own auth UI (a hub sets this on the plugin - * connections it manages, alongside supplying the token). Default: `true`. + * connections it manages, alongside supplying the token). + * + * @default true */ simpleAuth?: boolean wsOptions?: Partial diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 679c7386..3b93ec41 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -111,8 +111,8 @@ export interface DevframeCliOptions { /** * Authentication for the standalone dev server. * - * - `undefined` / `true` (default) — the standalone adapters (`cli` / - * `spa` / served `build`) auto-wire devframe's interactive OTP auth + * - `undefined` / `true` — the standalone adapters (`cli` / `spa` / + * served `build`) auto-wire devframe's interactive OTP auth * (`createInteractiveAuth`): an untrusted client can only reach * `anonymous:` methods until it exchanges the printed one-time code. * The adapter prints the code + magic-link banner once the server is @@ -126,6 +126,8 @@ export interface DevframeCliOptions { * * Hosted adapters (`vite`, `embedded`) ignore this and defer to the host's * auth; `@vitejs/devtools` honors the equivalent `devtools.clientAuth`. + * + * @default true */ auth?: boolean | DevframeAuthHandler /** From 6c2c3bdd37f1674a04fad5bc6d9cb3bdac6e2390 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 17 Jul 2026 03:35:42 +0000 Subject: [PATCH 3/3] refactor(auth): fold the meta token into getStoredAuthToken's existing arg Pass `options.authToken || connectionMeta.authToken` as the single token argument instead of adding a second parameter, per review. --- packages/devframe/src/client/rpc.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index d22dc2ce..5b1be907 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -215,13 +215,9 @@ export interface DevframeRpcClientMode { callOptional: DevframeRpcClient['callOptional'] } -function getStoredAuthToken(userAuthToken?: string, metaAuthToken?: string): string | undefined { +function getStoredAuthToken(userAuthToken?: string): string | undefined { const getters = [ - // Explicit option wins, then a token baked into the (hub-served) meta — - // the cross-origin channel a framed plugin relies on since it can't read - // the hub's `localStorage` — then this origin's own stored token. () => userAuthToken, - () => metaAuthToken, () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined, () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], @@ -337,7 +333,10 @@ export async function getDevframeRpcClient( const context: DevframeRpcContext = { rpc: undefined!, } - const authToken = getStoredAuthToken(options.authToken, connectionMeta.authToken) + // An explicit option wins, then a token baked into the (hub-served) meta — + // the cross-origin channel a framed plugin relies on since it can't read the + // hub's `localStorage` — then this origin's own stored token. + const authToken = getStoredAuthToken(options.authToken || connectionMeta.authToken) // Persist a resolved token so one supplied out-of-band — e.g. a host that // bootstraps trust by passing `authToken` (read from its own page URL query) // — survives reconnects. The token is still sent to the server via the WS