Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
@@ -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<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
{} 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'), '<!doctype html><title>test</title>', 'utf-8')
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/devframe/src/adapters/cac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {})
.option('--host <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`),
Expand Down
28 changes: 27 additions & 1 deletion packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -205,15 +207,39 @@ 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,
port,
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)
},
Expand Down
28 changes: 28 additions & 0 deletions packages/devframe/src/client/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down
87 changes: 75 additions & 12 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,25 @@ 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
/**
* 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<WsRpcChannelOptions>
rpcOptions?: Partial<BirpcOptions<DevframeRpcServerFunctions, DevframeRpcClientFunctions, boolean>>
cacheOptions?: boolean | Partial<RpcCacheOptions>
Expand Down Expand Up @@ -317,7 +333,10 @@ export async function getDevframeRpcClient(
const context: DevframeRpcContext = {
rpc: undefined!,
}
const authToken = getStoredAuthToken(options.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
Expand Down Expand Up @@ -450,16 +469,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<void> {
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<void> {
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).
Expand Down
15 changes: 15 additions & 0 deletions packages/devframe/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading