diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index a471a346..744f9234 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -442,6 +442,99 @@ describe('adapters/dev', () => { } }) + it('the `auth: false` option overrides a gated `def.cli.auth` (hosted adapter defers to the host)', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-option-off', + name: 'Auth Option Off', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + // Standalone would gate; a hosted caller passes `auth: false` to defer. + cli: { auth: true }, + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19440, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false, 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('the `auth: true` option forces the gate on even when `def.cli.auth` is false', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-option-on', + name: 'Auth Option On', + 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: 19450, host }) + // Silence the default OTP stdout banner for the test run. + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true }) + + try { + const client = connectWsClient(host, port) + // Gated: an empty handshake token is refused until a code is exchanged. + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: false }) + 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() + client.$close() + } + finally { + spy.mockRestore() + await handle.close() + } + }) + + it('the `--no-auth` flag forces the gate off even when the `auth: true` option opts in', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-flag-wins', + name: 'Auth Flag Wins', + 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: 19460, host }) + const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true, 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/dev.ts b/packages/devframe/src/adapters/dev.ts index 706d04b5..a6143567 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -68,6 +68,17 @@ export interface CreateDevServerOptions { * sources; a string opens that relative path. */ openBrowser?: boolean | string + /** + * Override how authentication resolves, taking precedence over + * `def.cli?.auth`. Pass `false` to skip the gate entirely (the standard + * choice for a **hosted** deployment where the host manages auth — see + * {@link viteDevBridge}); a {@link DevframeAuthHandler} to install a custom + * scheme; or `true` to force devframe's interactive OTP gate on. When + * omitted, auth resolves from `flags.auth` / `def.cli?.auth` (the standalone + * default: gated). The `--no-auth` flag (`flags.auth === false`) still forces + * the gate off regardless of this option. + */ + auth?: boolean | DevframeAuthHandler /** * Expose a route-based MCP server on the dev server (Streamable-HTTP). * Overrides `def.cli?.mcp`; `undefined` falls through to it. `false` @@ -212,8 +223,15 @@ export async function createDevServer( // 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 + // passed straight through to `startHttpAndWs`. An explicit `options.auth` + // wins over the definition default — a hosted adapter (e.g. `viteDevBridge`) + // passes `false` so the plugin's own gate never fires and the host owns auth + // — but the `--no-auth` flag can still force the gate off. + const authOption = flags.auth === false + ? false + : options.auth !== undefined + ? options.auth + : def.cli?.auth let authHandler: DevframeAuthHandler | undefined let resolvedAuth: boolean | DevframeAuthHandler if (authOption === false) { diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 57f00f21..82accb7b 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -1,3 +1,4 @@ +import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeDefinition } from '../types/devframe' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { resolve } from 'pathe' @@ -36,6 +37,19 @@ export interface ViteDevBridgeOptions { /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ flags?: Record } + /** + * Whether the bridged devframe runs its own auth gate. This is a **hosted** + * adapter — the devframe shares the host app's origin and the host owns + * authentication — so it defaults to `false`: the plugin's own gate never + * fires and its `cli.auth` default is ignored (matching devframe's + * hosted-deployment contract). Pass `true` to force devframe's interactive + * OTP gate on, or a {@link DevframeAuthHandler} to install a custom scheme. + * Only applies in bridge mode (`devMiddleware`); the static-mount mode + * starts no RPC server. + * + * @default false + */ + auth?: boolean | DevframeAuthHandler } export interface DevframeVitePlugin { @@ -62,6 +76,12 @@ export interface DevframeVitePlugin { * host-served SPA can discover the WS endpoint via * {@link connectDevframe}. * + * As a hosted adapter the bridge defers authentication to the host: its + * side-car RPC server runs with the plugin's own auth gate **off** by + * default (ignoring `def.cli?.auth`), so a plugin mounted this way never + * triggers its standalone OTP prompt. Opt back in per-mount with + * `options.auth` (`true` for devframe's interactive gate, or a handler). + * * Use bridge mode when integrating with frameworks that own the SPA * (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one * `dev` / `build` / `mcp` shell, reach for {@link createCac} instead. @@ -103,6 +123,9 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio port, flags: mw.flags, openBrowser: false, + // Hosted adapter: the host owns auth, so the bridged devframe's own + // gate stays off unless the caller explicitly opts back in. + auth: options.auth ?? false, }) } catch (e) { diff --git a/plugins/og/src/spa/vite.config.ts b/plugins/og/src/spa/vite.config.ts index ba285d2b..745400c8 100644 --- a/plugins/og/src/spa/vite.config.ts +++ b/plugins/og/src/spa/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ plugins: [ vue(), UnoCSS(), - ogVitePlugin({ devMiddleware: true, base: '/', auth: false }), + ogVitePlugin({ devMiddleware: true, base: '/' }), ], optimizeDeps: { exclude: ['@antfu/design'] }, build: { diff --git a/plugins/og/src/vite.ts b/plugins/og/src/vite.ts index cf17e5f5..e609509c 100644 --- a/plugins/og/src/vite.ts +++ b/plugins/og/src/vite.ts @@ -1,16 +1,16 @@ import type { DevframeVitePlugin, ViteDevBridgeOptions } from 'devframe/helpers/vite' import { viteDevBridge } from 'devframe/helpers/vite' -import ogDevframe, { createOgDevframe } from './index' +import ogDevframe from './index' export type { ViteDevBridgeOptions } -export interface OgVitePluginOptions extends ViteDevBridgeOptions { - /** Override standalone trust for the bridge; useful for local SPA development. */ - auth?: boolean -} +export type OgVitePluginOptions = ViteDevBridgeOptions +/** + * Mount the OG image preview into an existing Vite dev server. As a hosted + * adapter it defers authentication to the host, so the bridged devframe's own + * gate stays off by default — opt back in with `{ auth: true }`. + */ export function ogVitePlugin(options: OgVitePluginOptions = {}): DevframeVitePlugin { - const { auth, ...bridgeOptions } = options - const definition = auth === undefined ? ogDevframe : createOgDevframe({ auth }) - return viteDevBridge(definition, bridgeOptions) + return viteDevBridge(ogDevframe, options) } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts index 883cfcd8..da6b18f3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts @@ -1,10 +1,8 @@ /** * Generated by tsnapi — public API snapshot of `@devframes/plugin-og/vite` */ -// #region Interfaces -export interface OgVitePluginOptions extends ViteDevBridgeOptions { - auth?: boolean; -} +// #region Types +export type OgVitePluginOptions = ViteDevBridgeOptions; // #endregion // #region Functions diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 17c29a5e..45f23a19 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -11,6 +11,7 @@ export interface CreateDevServerOptions { ws?: DevframeWsOptions; app?: H3; openBrowser?: boolean | string; + auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; onReady?: (_: { origin: string; diff --git a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts index a4efd70d..bfdaa265 100644 --- a/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts @@ -22,6 +22,7 @@ export interface ViteDevBridgeOptions { host?: string; flags?: Record; }; + auth?: boolean | DevframeAuthHandler; } // #endregion