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
2 changes: 1 addition & 1 deletion alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export const alias = {
'devframe/node': r('devframe/src/node/index.ts'),
'devframe/constants': r('devframe/src/constants.ts'),
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'),
'devframe/utils/events': r('devframe/src/utils/events.ts'),
'devframe/utils/hash': r('devframe/src/utils/hash.ts'),
'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'),
'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'),
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
'devframe/utils/open': r('devframe/src/utils/open.ts'),
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] {
{ text: 'When Clauses', link: `${prefix}/guide/when-clauses` },
{ text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` },
{ text: 'Client', link: `${prefix}/guide/client` },
{ text: 'Security', link: `${prefix}/guide/security` },
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
{ text: 'Hub (multi-tool)', link: `${prefix}/guide/hub` },
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
Expand Down
24 changes: 18 additions & 6 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The client picks a mode automatically from the backend field. Mode-specific code

## Trust & auth (WebSocket mode)

Dev-mode connections require trust before the server accepts calls. The client handles this automatically: on first connect it submits the locally-stored auth token, and `ensureTrusted()` resolves once the server accepts.
Dev-mode connections become trusted by authenticating. A client that authenticated before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it:

```ts
const rpc = await connectDevframe()
Expand All @@ -75,21 +75,33 @@ const rpc = await connectDevframe()
const trusted = await rpc.ensureTrusted()

if (!trusted) {
console.warn('Auth denied')
console.warn('Not authenticated yet')
}
```

### Replacing the token
### Authenticating with a one-time code

For tokens supplied from a different source (e.g. copy-pasted from CLI output), swap one in without reloading:
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:

```ts
const ok = await rpc.requestTrustWithToken('another-token')
const ok = await rpc.requestTrustWithCode('047204')
```

The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails.

To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities.

### Re-using an existing token

Authenticate with a token obtained elsewhere (e.g. another surface) without reloading:

```ts
const ok = await rpc.requestTrustWithToken('a1b2c3…')
```

### Broadcast-channel sync

`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with it automatically no reload required.
`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab authenticates — or an auth page announces a tokenevery open client trusts it automatically, no reload required.

## Calling functions

Expand Down
76 changes: 76 additions & 0 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
outline: deep
---

# Security

Devframe tools are secure by default: connections bind to `localhost`, and dev-mode RPC requires a trust handshake before a browser is accepted. This page covers the trust model and the practices that keep a tool safe as it moves beyond a single developer's machine.

## Trust model

An RPC handler runs with the full privileges of the process hosting it — filesystem, child processes, network. A trusted connection can call any registered function, so the boundary that matters is *who is allowed to connect*.

Two postures cover that boundary:

- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and authentication UI.
- **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction.

> [!WARNING]
> `auth: false` trusts every connection that can reach the port. Only use it when the surface is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment.

## 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.

1. A fresh client connects unauthenticated and calls `devframe:anonymous:auth` with its stored token (empty on first run). The server returns `{ isTrusted: false }`, so the trust gate stays open while the UI prompts for a code.
2. The dev server shows a 6-digit one-time code in the developer's terminal.
3. The developer enters it; the browser calls `requestTrustWithCode(code)` → `devframe:auth:exchange`.
4. The server verifies the code, mints a high-entropy bearer token, records it as trusted, marks the session trusted, and returns the token.
5. The browser persists the token and presents it on reconnect (`devframe:anonymous:auth` → `verifyAuthToken`); sibling tabs receive it over the `devframe-auth` channel and become trusted too.

The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts — which is what keeps a short code brute-force resistant. Show it only in a trusted channel (the terminal), never over the network.

The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event.

### Auth methods

Devframe owns the wire contract; the host adapter registers the handlers on top of the `devframe/node/auth` primitives (the standalone server registers a noop auto-trust handler when `auth: false`).

| RPC method | Direction | Shape |
|------------|-----------|-------|
| `devframe:anonymous:auth` | client → server | `{ authToken, ua, origin }` → `{ isTrusted }` — re-authenticate a stored token |
| `devframe:auth:exchange` | client → server | `{ code, ua, origin }` → `{ authToken \| null }` — exchange a one-time code for a token |
| `devframe:auth:revoked` | server → client | event — the connection's token was revoked |

Node primitives (`devframe/node/auth`):

| Function | Role |
|----------|------|
| `getTempAuthCode()` / `refreshTempAuthCode()` | read / rotate the current one-time code to display |
| `exchangeTempAuthCode(code, session, { ua, origin }, storage)` | verify a code, mint + store the token, trust the session, return the token (or `null`) |
| `verifyAuthToken(token, session, storage)` | trust a session presenting a known token (reconnect) |
| `buildOtpAuthUrl(origin, code?)` | build a magic-link URL embedding the code |
| `revokeAuthToken(context, storage, token)` | delete a token and disconnect any sessions using it |

Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate).

### 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):

```
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
```

`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code.

Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`.

## Practices for tools built on devframe

- **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do.
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere.
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output.
- **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin.
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.
20 changes: 11 additions & 9 deletions docs/helpers/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,27 @@ const wire = structuredCloneStringify(new Map([['a', 1]]))
const value = structuredCloneParse<Map<string, number>>(wire)
```

### `devframe/utils/human-id`
### `devframe/utils/nanoid`

Generate a human-readable, lowercase, dash-separated random ID.
Tiny URL-safe random ID generator (vendored, no runtime dependency).

```ts
import { humanId } from 'devframe/utils/human-id'
import { nanoid } from 'devframe/utils/nanoid'

humanId() // 'bright-orange-tiger'
nanoid() // 21 chars
nanoid(10) // 10 chars
```

### `devframe/utils/nanoid`
### `devframe/utils/crypto-token`

Tiny URL-safe random ID generator (vendored, no runtime dependency).
Cryptographically-secure token helpers built on the WebCrypto global, so they run in browsers and Node alike. Use these for bearer credentials and human-typed one-time codes.

```ts
import { nanoid } from 'devframe/utils/nanoid'
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'

nanoid() // 21 chars
nanoid(10) // 10 chars
randomToken() // 32-char hex, 128 bits of entropy — use as a bearer token
randomDigits(6) // '047204' — uniform, leading zeros preserved
timingSafeEqual(input, secret) // constant-time string comparison
```

### `devframe/utils/promise`
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
"./types": "./dist/types/index.mjs",
"./utils/colors": "./dist/utils/colors.mjs",
"./utils/crypto-token": "./dist/utils/crypto-token.mjs",
"./utils/events": "./dist/utils/events.mjs",
"./utils/hash": "./dist/utils/hash.mjs",
"./utils/human-id": "./dist/utils/human-id.mjs",
"./utils/launch-editor": "./dist/utils/launch-editor.mjs",
"./utils/nanoid": "./dist/utils/nanoid.mjs",
"./utils/open": "./dist/utils/open.mjs",
Expand Down
83 changes: 83 additions & 0 deletions packages/devframe/src/client/__tests__/otp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { authenticateWithUrlOtp, consumeOtpFromUrl, readOtpFromUrl } from '../otp'

afterEach(() => {
vi.unstubAllGlobals()
})

describe('otp url helpers', () => {
it('reads the OTP from the page URL query string (default param)', () => {
vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' })
expect(readOtpFromUrl()).toBe('123456')
})

it('supports a custom param name', () => {
vi.stubGlobal('location', { search: '?code=999', href: 'http://localhost:3000/?code=999' })
expect(readOtpFromUrl('code')).toBe('999')
})

it('returns undefined when the param is absent and is safe without location', () => {
vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' })
expect(readOtpFromUrl()).toBeUndefined()
vi.stubGlobal('location', undefined)
expect(readOtpFromUrl()).toBeUndefined()
expect(() => consumeOtpFromUrl()).not.toThrow()
})

it('consume reads then strips the OTP via history.replaceState, keeping other params', () => {
const replaceState = vi.fn()
vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' })
vi.stubGlobal('history', { state: { a: 1 }, replaceState })

expect(consumeOtpFromUrl()).toBe('123456')
expect(replaceState).toHaveBeenCalledTimes(1)
const [state, , href] = replaceState.mock.calls[0]
expect(state).toEqual({ a: 1 })
expect(href).toBe('http://localhost:3000/?x=1')
})

it('does not touch the URL when no OTP is present', () => {
const replaceState = vi.fn()
vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' })
vi.stubGlobal('history', { state: null, replaceState })

expect(consumeOtpFromUrl()).toBeUndefined()
expect(replaceState).not.toHaveBeenCalled()
})
})

describe('authenticateWithUrlOtp', () => {
it('exchanges the OTP via the client and resolves true on success', async () => {
vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' })
vi.stubGlobal('history', { state: null, replaceState: vi.fn() })
const requestTrustWithCode = vi.fn().mockResolvedValue(true)

const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })

expect(requestTrustWithCode).toHaveBeenCalledWith('123456')
expect(ok).toBe(true)
})

it('returns false (and does not exchange) when no OTP is present', async () => {
vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' })
const requestTrustWithCode = vi.fn()

const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode })

expect(requestTrustWithCode).not.toHaveBeenCalled()
expect(ok).toBe(false)
})

it('skips the exchange but still consumes the OTP when already trusted', async () => {
const replaceState = vi.fn()
vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' })
vi.stubGlobal('history', { state: null, replaceState })
const requestTrustWithCode = vi.fn()

const ok = await authenticateWithUrlOtp({ isTrusted: true, requestTrustWithCode })

expect(ok).toBe(true)
expect(requestTrustWithCode).not.toHaveBeenCalled()
expect(replaceState).toHaveBeenCalledTimes(1)
})
})
1 change: 1 addition & 0 deletions packages/devframe/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getDevframeRpcClient } from './rpc'

export * from './otp'
export * from './rpc'
export * from './rpc-streaming'
export * from './scope'
Expand Down
66 changes: 66 additions & 0 deletions packages/devframe/src/client/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { DevframeRpcClient } from './rpc'
import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants'

// Browser-only helpers for "magic link" authentication: a host prints a URL
// carrying a one-time authentication code (OTP), and the client reads it,
// exchanges it for a token, and removes it from the address bar. Only the
// short-lived, single-use OTP ever rides the URL — never the resulting token.

/**
* Read a one-time authentication code (OTP) from the current page URL's query
* string, without side effects. Returns `undefined` when the parameter is absent.
*/
export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined {
try {
return new URLSearchParams(globalThis.location?.search).get(param) || undefined
}
catch {
return undefined
}
}

function stripParamFromUrl(param: string): void {
try {
const url = new URL(globalThis.location!.href)
if (!url.searchParams.has(param))
return
url.searchParams.delete(param)
globalThis.history?.replaceState(globalThis.history.state, '', url.href)
}
catch {}
}

/**
* Read the one-time code from the page URL and remove it from the address bar
* (and the current history entry), so the single-use code isn't left in the
* URL, browser history, or a `Referer`. Returns the code, or `undefined` when
* absent.
*/
export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined {
const code = readOtpFromUrl(param)
if (code)
stripParamFromUrl(param)
return code
}

/**
* Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and
* exchange it for a token via the client. Resolves `true` when the client is
* authenticated (already trusted, or the exchange succeeded), and `false` when
* no code is present or the exchange failed.
*
* Higher-level integrations (e.g. Vite DevTools) that want to drive their own
* authentication UI can disable `connectDevframe`'s built-in handling with
* `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves.
*/
export async function authenticateWithUrlOtp(
rpc: Pick<DevframeRpcClient, 'isTrusted' | 'requestTrustWithCode'>,
options: { param?: string } = {},
): Promise<boolean> {
const code = consumeOtpFromUrl(options.param ?? DEVFRAME_OTP_URL_PARAM)
if (!code)
return false
if (rpc.isTrusted)
return true
return rpc.requestTrustWithCode(code)
}
2 changes: 2 additions & 0 deletions packages/devframe/src/client/rpc-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export async function createStaticRpcClientMode(
isTrusted: true,
requestTrust: async () => true,
requestTrustWithToken: async () => true,
// Static backends are always trusted, so there's nothing to exchange.
requestTrustWithCode: async () => null,
ensureTrusted: async () => true,
call: (...args: any): any => staticCaller.call(
args[0] as string,
Expand Down
Loading
Loading