Skip to content

Commit e170f26

Browse files
authored
fix(vite): defer auth to the host when bridging a devframe (#132)
1 parent 1a6834c commit e170f26

8 files changed

Lines changed: 149 additions & 15 deletions

File tree

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,99 @@ describe('adapters/dev', () => {
442442
}
443443
})
444444

445+
it('the `auth: false` option overrides a gated `def.cli.auth` (hosted adapter defers to the host)', async () => {
446+
const devframe = defineDevframe({
447+
id: 'devframe-auth-option-off',
448+
name: 'Auth Option Off',
449+
version: '0.0.0',
450+
packageName: 'devframe-test',
451+
homepage: 'https://example.test',
452+
description: 'Test devframe.',
453+
// Standalone would gate; a hosted caller passes `auth: false` to defer.
454+
cli: { auth: true },
455+
setup: (ctx: DevframeNodeContext) => {
456+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
457+
},
458+
})
459+
const host = '127.0.0.1'
460+
const port = await getPort({ port: 19440, host })
461+
const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: false })
462+
463+
try {
464+
const client = connectWsClient(host, port)
465+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
466+
expect(handshake).toEqual({ isTrusted: true })
467+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
468+
client.$close()
469+
}
470+
finally {
471+
await handle.close()
472+
}
473+
})
474+
475+
it('the `auth: true` option forces the gate on even when `def.cli.auth` is false', async () => {
476+
const devframe = defineDevframe({
477+
id: 'devframe-auth-option-on',
478+
name: 'Auth Option On',
479+
version: '0.0.0',
480+
packageName: 'devframe-test',
481+
homepage: 'https://example.test',
482+
description: 'Test devframe.',
483+
cli: { auth: false },
484+
setup: (ctx: DevframeNodeContext) => {
485+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
486+
},
487+
})
488+
const host = '127.0.0.1'
489+
const port = await getPort({ port: 19450, host })
490+
// Silence the default OTP stdout banner for the test run.
491+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
492+
const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true })
493+
494+
try {
495+
const client = connectWsClient(host, port)
496+
// Gated: an empty handshake token is refused until a code is exchanged.
497+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
498+
expect(handshake).toEqual({ isTrusted: false })
499+
const code = getTempAuthCode()
500+
const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null }
501+
expect(exchange.authToken).toBeTruthy()
502+
client.$close()
503+
}
504+
finally {
505+
spy.mockRestore()
506+
await handle.close()
507+
}
508+
})
509+
510+
it('the `--no-auth` flag forces the gate off even when the `auth: true` option opts in', async () => {
511+
const devframe = defineDevframe({
512+
id: 'devframe-auth-flag-wins',
513+
name: 'Auth Flag Wins',
514+
version: '0.0.0',
515+
packageName: 'devframe-test',
516+
homepage: 'https://example.test',
517+
description: 'Test devframe.',
518+
setup: (ctx: DevframeNodeContext) => {
519+
ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' })
520+
},
521+
})
522+
const host = '127.0.0.1'
523+
const port = await getPort({ port: 19460, host })
524+
const handle = await createDevServer(devframe, { host, port, openBrowser: false, auth: true, flags: { auth: false } })
525+
526+
try {
527+
const client = connectWsClient(host, port)
528+
const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE)
529+
expect(handshake).toEqual({ isTrusted: true })
530+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
531+
client.$close()
532+
}
533+
finally {
534+
await handle.close()
535+
}
536+
})
537+
445538
it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
446539
const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
447540
const devframe = defineDevframe({

packages/devframe/src/adapters/dev.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ export interface CreateDevServerOptions {
6868
* sources; a string opens that relative path.
6969
*/
7070
openBrowser?: boolean | string
71+
/**
72+
* Override how authentication resolves, taking precedence over
73+
* `def.cli?.auth`. Pass `false` to skip the gate entirely (the standard
74+
* choice for a **hosted** deployment where the host manages auth — see
75+
* {@link viteDevBridge}); a {@link DevframeAuthHandler} to install a custom
76+
* scheme; or `true` to force devframe's interactive OTP gate on. When
77+
* omitted, auth resolves from `flags.auth` / `def.cli?.auth` (the standalone
78+
* default: gated). The `--no-auth` flag (`flags.auth === false`) still forces
79+
* the gate off regardless of this option.
80+
*/
81+
auth?: boolean | DevframeAuthHandler
7182
/**
7283
* Expose a route-based MCP server on the dev server (Streamable-HTTP).
7384
* Overrides `def.cli?.mcp`; `undefined` falls through to it. `false`
@@ -212,8 +223,15 @@ export async function createDevServer(
212223
// interactive OTP handler and print its code + magic-link banner once the
213224
// server is listening (a gate is useless without surfacing the code). A
214225
// `false` (including the `--no-auth` flag) opts out; a handler object is
215-
// passed straight through to `startHttpAndWs`.
216-
const authOption = flags.auth === false ? false : def.cli?.auth
226+
// passed straight through to `startHttpAndWs`. An explicit `options.auth`
227+
// wins over the definition default — a hosted adapter (e.g. `viteDevBridge`)
228+
// passes `false` so the plugin's own gate never fires and the host owns auth
229+
// — but the `--no-auth` flag can still force the gate off.
230+
const authOption = flags.auth === false
231+
? false
232+
: options.auth !== undefined
233+
? options.auth
234+
: def.cli?.auth
217235
let authHandler: DevframeAuthHandler | undefined
218236
let resolvedAuth: boolean | DevframeAuthHandler
219237
if (authOption === false) {

packages/devframe/src/helpers/vite.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DevframeAuthHandler } from '../node/auth/handler'
12
import type { DevframeDefinition } from '../types/devframe'
23
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
34
import { resolve } from 'pathe'
@@ -36,6 +37,19 @@ export interface ViteDevBridgeOptions {
3637
/** Flag bag forwarded to `def.setup(ctx, { flags })`. */
3738
flags?: Record<string, unknown>
3839
}
40+
/**
41+
* Whether the bridged devframe runs its own auth gate. This is a **hosted**
42+
* adapter — the devframe shares the host app's origin and the host owns
43+
* authentication — so it defaults to `false`: the plugin's own gate never
44+
* fires and its `cli.auth` default is ignored (matching devframe's
45+
* hosted-deployment contract). Pass `true` to force devframe's interactive
46+
* OTP gate on, or a {@link DevframeAuthHandler} to install a custom scheme.
47+
* Only applies in bridge mode (`devMiddleware`); the static-mount mode
48+
* starts no RPC server.
49+
*
50+
* @default false
51+
*/
52+
auth?: boolean | DevframeAuthHandler
3953
}
4054

4155
export interface DevframeVitePlugin {
@@ -62,6 +76,12 @@ export interface DevframeVitePlugin {
6276
* host-served SPA can discover the WS endpoint via
6377
* {@link connectDevframe}.
6478
*
79+
* As a hosted adapter the bridge defers authentication to the host: its
80+
* side-car RPC server runs with the plugin's own auth gate **off** by
81+
* default (ignoring `def.cli?.auth`), so a plugin mounted this way never
82+
* triggers its standalone OTP prompt. Opt back in per-mount with
83+
* `options.auth` (`true` for devframe's interactive gate, or a handler).
84+
*
6585
* Use bridge mode when integrating with frameworks that own the SPA
6686
* (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one
6787
* `dev` / `build` / `mcp` shell, reach for {@link createCac} instead.
@@ -103,6 +123,9 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio
103123
port,
104124
flags: mw.flags,
105125
openBrowser: false,
126+
// Hosted adapter: the host owns auth, so the bridged devframe's own
127+
// gate stays off unless the caller explicitly opts back in.
128+
auth: options.auth ?? false,
106129
})
107130
}
108131
catch (e) {

plugins/og/src/spa/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default defineConfig({
1212
plugins: [
1313
vue(),
1414
UnoCSS(),
15-
ogVitePlugin({ devMiddleware: true, base: '/', auth: false }),
15+
ogVitePlugin({ devMiddleware: true, base: '/' }),
1616
],
1717
optimizeDeps: { exclude: ['@antfu/design'] },
1818
build: {

plugins/og/src/vite.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import type { DevframeVitePlugin, ViteDevBridgeOptions } from 'devframe/helpers/vite'
22
import { viteDevBridge } from 'devframe/helpers/vite'
3-
import ogDevframe, { createOgDevframe } from './index'
3+
import ogDevframe from './index'
44

55
export type { ViteDevBridgeOptions }
66

7-
export interface OgVitePluginOptions extends ViteDevBridgeOptions {
8-
/** Override standalone trust for the bridge; useful for local SPA development. */
9-
auth?: boolean
10-
}
7+
export type OgVitePluginOptions = ViteDevBridgeOptions
118

9+
/**
10+
* Mount the OG image preview into an existing Vite dev server. As a hosted
11+
* adapter it defers authentication to the host, so the bridged devframe's own
12+
* gate stays off by default — opt back in with `{ auth: true }`.
13+
*/
1214
export function ogVitePlugin(options: OgVitePluginOptions = {}): DevframeVitePlugin {
13-
const { auth, ...bridgeOptions } = options
14-
const definition = auth === undefined ? ogDevframe : createOgDevframe({ auth })
15-
return viteDevBridge(definition, bridgeOptions)
15+
return viteDevBridge(ogDevframe, options)
1616
}

tests/__snapshots__/tsnapi/@devframes/plugin-og/vite.snapshot.d.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `@devframes/plugin-og/vite`
33
*/
4-
// #region Interfaces
5-
export interface OgVitePluginOptions extends ViteDevBridgeOptions {
6-
auth?: boolean;
7-
}
4+
// #region Types
5+
export type OgVitePluginOptions = ViteDevBridgeOptions;
86
// #endregion
97

108
// #region Functions

tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface CreateDevServerOptions {
1111
ws?: DevframeWsOptions;
1212
app?: H3;
1313
openBrowser?: boolean | string;
14+
auth?: boolean | DevframeAuthHandler;
1415
mcp?: boolean | McpRouteOptions;
1516
onReady?: (_: {
1617
origin: string;

tests/__snapshots__/tsnapi/devframe/helpers/vite.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ViteDevBridgeOptions {
2222
host?: string;
2323
flags?: Record<string, unknown>;
2424
};
25+
auth?: boolean | DevframeAuthHandler;
2526
}
2627
// #endregion
2728

0 commit comments

Comments
 (0)