Skip to content

Commit 6890256

Browse files
antfubotantfu
andauthored
feat(examples): enable auth on all hub examples (#204)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 2fb9bf9 commit 6890256

16 files changed

Lines changed: 320 additions & 39 deletions

File tree

examples/hub-hono-minimal/src/app.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,9 @@ export const hub: HubInstance = globalRef.__hubHonoMinimal ??= initHub({
4343
// fetches at boot and feeds into `--devframe-primary` (see
4444
// `@devframes/hub-ui`'s `primary-ramp.css`).
4545
ui: createUi({ branding: { primaryColor: '#e36002', productName: 'Devframes on Hono' } }),
46-
// Single-user localhost demo: reachable only on loopback, so it opts out
47-
// of the gate for a no-friction dev experience. A hub reachable beyond
48-
// localhost should gate (see docs/guide/security.md).
49-
auth: false,
46+
// Gate with devframe's interactive OTP (the default). The hub prints a
47+
// 6-digit code + magic link on startup, and the reference UI's authorization
48+
// view exchanges it for a bearer token. See docs/guide/security.md.
5049
configure(ctx) {
5150
ctx.commands.register({
5251
id: 'example:hub-hono-minimal:ping',

examples/hub-hono-minimal/src/bun.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import process from 'node:process'
22
import { createContextRpcServer } from 'devframe/internal'
3+
import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
34
import { attachBunWsTransport } from 'devframe/rpc/transports/ws-bun'
45
import { app, hub } from './app'
56

@@ -30,9 +31,11 @@ declare const Bun: {
3031
export async function startBunServer(port: number): Promise<{ port: number, close: () => Promise<void> }> {
3132
await hub.ready
3233
const context = await hub.context
33-
// Matches `app.ts`'s `auth: false` — this single-user localhost demo owns
34-
// its trust boundary. A gated host passes the same handler it gave `initHub`.
35-
const core = createContextRpcServer({ context, auth: false })
34+
// Matches `app.ts`'s gated `initHub`: bind the same interactive-OTP handler to
35+
// Bun's own WS transport so this path enforces the same trust boundary. It
36+
// shares the context's auth storage and one-time code, so a client authorizes
37+
// once regardless of which runtime serves the socket.
38+
const core = createContextRpcServer({ context, auth: createInteractiveAuth(context) })
3639
const tier = await attachBunWsTransport(core)
3740
const upgradePath = `${hub.base}__ws`
3841

examples/hub-next-minimal/src/client/hub.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ async function loadHub(): Promise<HubInstance> {
8888
configure(ctx) {
8989
ctx.docks.register(jsonRenderDock)
9090
},
91-
// Single-user localhost demo: opts out of the gate. A hub reachable
92-
// beyond localhost should gate (see docs/guide/security.md).
93-
auth: false,
91+
// Gate with devframe's interactive OTP (the default). The hub prints a
92+
// 6-digit code + magic link on startup, and the reference UI's
93+
// authorization view exchanges it for a bearer token. See
94+
// docs/guide/security.md.
9495
})
9596
}
9697

examples/hub-next/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pnpm install
1111
pnpm --filter hub-next dev
1212
```
1313

14+
On first load the hub asks you to authorize. `initHub()` gates every connection by default (devframe's interactive OTP), so it prints a 6-digit code and a magic link in the terminal, and the page shows an authorization view that exchanges the code for a bearer token stored in the browser. The client shell opts out of devframe's native `prompt()` (`simpleAuth: false`) to render that view; open the magic link instead to authorize without typing. Each embedded SPA then inherits the token the host page stored.
15+
1416
Open the printed URL. The dock on the left lists every mounted tool with its icon:
1517

1618
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** - the built-in plugins, each an entry in `initHub`'s `devframes` list
@@ -40,6 +42,7 @@ The instance is memoized on `globalThis`, so Next's dev-time module re-evaluatio
4042

4143
- `initHub()` boots a whole hub with no Vite-specific code path - devframes, shared RPC registry, WS transport, MCP, and discovery behind one framework-agnostic handler
4244
- Every `devframes` entry is mounted as a dock and served at `/__devframes/<id>/` with its own `__connection.json`, so the embedded SPA connects straight back to the hub
45+
- One authorization covers the whole hub: `initHub()` gates the shared transport by default, so a single OTP handshake trusts every mounted frame, the discovery endpoints, and the built-ins. The shell drives its own authorization view (`simpleAuth: false`) and each embedded SPA inherits the stored token
4346
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC - byte-for-byte the same protocol the Vite host speaks
4447
- `createDevframeClientHost()` boots the hub's framework-level client runtime in the host page: it publishes the shared client context and imports each dock's `clientScript` (here, the a11y agent) so plugins run code in the page being inspected
4548
- The **JSON Render** dock renders through a **local React renderer** (`src/client/json-render/react-renderer.tsx` - a compact React port of the base catalog) registered at `createDevframeClientHost({ renderers })`. The hub *also* publishes the reference Vue frontend through its renderer manifest (`renderers: [jsonRenderUiRenderer()]` on `initHub`), but local registration takes precedence - witnessing that any frontend implementing the `JsonRenderDockRenderer` contract can replace the reference one. Delete the local `renderers` option and the same dock renders via the manifest-served module instead. (The sibling `hub-vite` witness ships no local renderer and consumes the manifest directly - the other side of the swap seam.)
@@ -56,5 +59,5 @@ The plugins run node-side (child processes, the native `zigpty` PTY backend) and
5659
| `src/client/devframe/next-devframe-hub.ts` | The Next host - one `initHub()` call: devframes (incl. the a11y agent's dock `clientScript`), hub RPCs, commands, the json-render dock + renderer manifest, instance-registry registration |
5760
| `src/client/devframe/unrendered-dock.ts` | A dock type registered with no renderer on purpose - the missing-renderer fallback witness |
5861
| `src/client/app/%5F_devframes/[[...path]]/route.ts` | The one catch-all - delegates every `/__devframes/*` request to the instance's `handler` |
59-
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol |
62+
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol, including the interactive-OTP authorization view |
6063
| `src/client/app/icons.ts` | Offline Phosphor icons for the dock |

examples/hub-next/src/client/app/page.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from '@devframes/hub/types'
1111
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
1212
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
13+
import type { FormEvent } from 'react'
1314
import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client'
1415
import { useEffect, useMemo, useRef, useState } from 'react'
1516
import { createReactJsonRenderDockRenderer } from '../json-render/react-renderer'
@@ -253,8 +254,89 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
253254
return <span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">{initial}</span>
254255
}
255256

257+
// ── authorization gate (interactive OTP) ────────────────────────────────────
258+
// The hub gates every connection; this shell opts out of devframe's native
259+
// `prompt()` (`simpleAuth: false`) and renders its own authorization view,
260+
// mirroring the reference UI's `ViewBuiltinClientAuthNotice`. It shows only
261+
// once the handshake is refused — a stored token or the magic-link OTP
262+
// authorizes silently and this never mounts.
263+
function AuthOverlay({ rpc }: { rpc: DevframeRpcClient }) {
264+
const CODE_LENGTH = 6
265+
const [code, setCode] = useState('')
266+
const [error, setError] = useState('')
267+
const [verifying, setVerifying] = useState(false)
268+
const inputRef = useRef<HTMLInputElement | null>(null)
269+
270+
useEffect(() => {
271+
inputRef.current?.focus()
272+
}, [])
273+
274+
async function submit(event: FormEvent) {
275+
event.preventDefault()
276+
if (code.length < CODE_LENGTH || verifying)
277+
return
278+
setVerifying(true)
279+
setError('')
280+
try {
281+
const ok = await rpc.requestTrustWithCode(code)
282+
if (!ok) {
283+
setError('That code didn’t match. Check your terminal and try again.')
284+
setCode('')
285+
inputRef.current?.focus()
286+
}
287+
// On success the boot effect's trust listener unmounts this overlay.
288+
}
289+
catch {
290+
setError('Something went wrong while authorizing. Please try again.')
291+
}
292+
finally {
293+
setVerifying(false)
294+
}
295+
}
296+
297+
return (
298+
<div className="fixed inset-0 grid place-items-center of-auto bg-base p8 color-base">
299+
<div className="w-full max-w-100 flex flex-col items-center text-center">
300+
<div className="grid size-16 place-items-center rounded-2xl bg-active">
301+
<span className="i-ph-shield-check-duotone text-4xl color-active" />
302+
</div>
303+
<h1 className="mt5 text-2xl font-bold tracking-tight">Authorize Next Devframe Hub</h1>
304+
<p className="mt2 max-w-88 text-sm op-fade leading-relaxed">
305+
This hub can access your server, read your filesystem, and run commands.
306+
Confirm it&apos;s you before continuing.
307+
</p>
308+
<form onSubmit={submit} className="mt6 w-full flex flex-col items-center gap-4 rounded-xl border border-base bg-secondary p6 shadow-sm" autoComplete="off">
309+
<p className="text-sm op-fade">
310+
Enter the
311+
{' '}
312+
<span className="font-mono color-active">6-digit code</span>
313+
{' '}
314+
printed in your terminal.
315+
</p>
316+
<input
317+
ref={inputRef}
318+
value={code}
319+
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, CODE_LENGTH))}
320+
inputMode="numeric"
321+
autoComplete="one-time-code"
322+
maxLength={CODE_LENGTH}
323+
aria-label="One-time authorization code"
324+
placeholder="••••••"
325+
className="w-56 rounded-lg border border-base bg-base px3 py2 text-center text-2xl font-mono tracking-[0.4em] color-base outline-none focus:border-active"
326+
/>
327+
<button type="submit" disabled={code.length < CODE_LENGTH || verifying} className="btn-primary w-full justify-center py2!">
328+
{verifying ? 'Authorizing…' : 'Authorize'}
329+
</button>
330+
<p className="min-h-5 text-sm text-red-500" role="alert" aria-live="assertive">{error}</p>
331+
</form>
332+
</div>
333+
</div>
334+
)
335+
}
336+
256337
export default function Page() {
257338
const [status, setStatus] = useState<Status>({ text: 'Connecting...' })
339+
const [authNeeded, setAuthNeeded] = useState(false)
258340
const [transport, setTransport] = useState<string | null>(null)
259341
const [transportPref, setTransportPref] = useState<TransportPref>('auto')
260342
const [docks, setDocks] = useState<DevframeDockEntry[]>([])
@@ -278,17 +360,51 @@ export default function Page() {
278360
useEffect(() => {
279361
let cancelled = false
280362
let cleanup: (() => void) | undefined
363+
let offAuth: (() => void) | undefined
281364

282365
async function run() {
283366
try {
284367
const pref = readTransportPref()
285368
setTransportPref(pref)
286-
const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: pref })
369+
// The hub gates by default (interactive OTP). `simpleAuth: false` opts
370+
// out of devframe's native `prompt()` so this shell drives its own
371+
// authorization view; the magic-link OTP (`?devframe_otp=`) is still
372+
// consumed automatically. `connectDevframe` resolves before the trust
373+
// handshake settles, so hold the client-host boot until trusted.
374+
const rpc = await connectDevframe({ baseURL: HUB_BASE, transport: pref, simpleAuth: false })
287375
if (cancelled)
288376
return
289377

290378
rpcRef.current = rpc
291379
setTransport(rpc.transport)
380+
381+
// Gate on trust. A stored token or the magic link resolves silently
382+
// (the overlay never shows); otherwise the connection settles
383+
// `unauthorized` and the overlay collects the code the user types.
384+
if (!rpc.isTrusted) {
385+
await new Promise<void>((resolve) => {
386+
const offTrust = rpc.events.on('rpc:is-trusted:updated', (trusted) => {
387+
if (trusted) {
388+
offAuth?.()
389+
resolve()
390+
}
391+
})
392+
const reveal = (status: string): void => {
393+
if (status === 'unauthorized')
394+
setAuthNeeded(true)
395+
}
396+
const offStatus = rpc.events.on('connection:status', reveal)
397+
offAuth = () => {
398+
offTrust()
399+
offStatus()
400+
}
401+
reveal(rpc.status)
402+
})
403+
if (cancelled)
404+
return
405+
setAuthNeeded(false)
406+
}
407+
292408
setStatus({ text: `Connected: transport=${rpc.transport}`, kind: 'ready' })
293409

294410
// Boot the framework-level client host: it builds the shared client
@@ -375,6 +491,7 @@ export default function Page() {
375491

376492
return () => {
377493
cancelled = true
494+
offAuth?.()
378495
cleanup?.()
379496
rpcRef.current = null
380497
}
@@ -512,6 +629,7 @@ export default function Page() {
512629

513630
return (
514631
<div className="h-full flex flex-col bg-base color-base">
632+
{authNeeded && rpcRef.current && <AuthOverlay rpc={rpcRef.current} />}
515633
<header className="shrink-0 flex items-center gap-3 h-nav px-3 border-b border-base bg-base">
516634
<h1 className="m0 flex items-center gap-1.5 shrink-0 text-sm font-semibold select-none">
517635
<span className="i-ph-squares-four-duotone text-base color-active" />

examples/hub-next/src/client/devframe/next-devframe-hub.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,10 @@ export async function nextDevframeHub(
216216
cwd,
217217
origin,
218218
host: hostName,
219-
// Single-user localhost demo: the side-car is reachable only on loopback,
220-
// so it opts out of the gate for a no-friction dev experience. A hub
221-
// reachable beyond localhost should gate (see `docs/guide/security.md`).
222-
auth: false,
219+
// Gate access with devframe's interactive OTP (the default): the hub
220+
// prints a 6-digit code + magic link on startup, and the client shell
221+
// (`app/page.tsx`) drives its own authorization view to exchange the code
222+
// for a bearer token. See `docs/guide/security.md`.
223223
// The aggregate MCP endpoint at `/__devframes/__mcp` - the hub's agent
224224
// surface (agent-flagged commands, plugin tools, `devframe:state:read`)
225225
// over the same catch-all route as the SPAs.

examples/hub-next/tests/next-devframe-hub.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HubInstance } from '@devframes/hub/initiate'
2+
import { getTempAuthCode } from 'devframe/node/auth'
23
import { createRpcClient } from 'devframe/rpc/client'
34
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
45
import { getPort } from 'get-port-please'
@@ -21,6 +22,23 @@ function bootRpc(hub: HubInstance) {
2122
return createRpcClient<any, any>({}, { channel })
2223
}
2324

25+
/**
26+
* The hub gates by default (interactive OTP), so a fresh connection is
27+
* untrusted. Exchange the current one-time code for a bearer token over the
28+
* anonymous handshake RPC, which marks this session trusted — mirroring what a
29+
* browser client does after the user enters the code from the terminal.
30+
*/
31+
async function bootTrustedRpc(hub: HubInstance) {
32+
const rpc = bootRpc(hub)
33+
const result = await rpc.$call('anonymous:devframe:auth:exchange', {
34+
code: getTempAuthCode(),
35+
ua: 'vitest',
36+
origin: 'http://127.0.0.1:3000',
37+
}) as { authToken: string | null }
38+
expect(result.authToken).toBeTruthy()
39+
return rpc
40+
}
41+
2442
describe('next-devframe-hub (example)', () => {
2543
let hub: HubInstance | undefined
2644

@@ -81,11 +99,33 @@ describe('next-devframe-hub (example)', () => {
8199
expect(dockIds).toContain('devframes_plugin_assets')
82100
})
83101

84-
it('lists startup and demo messages through the kit-local RPC', async () => {
102+
it('gates untrusted calls until the OTP handshake completes', async () => {
85103
hub = await nextDevframeHub({ host: '127.0.0.1' })
86104
await hub.ready
87105

106+
// A fresh connection is untrusted: a non-anonymous call is refused.
88107
const rpc = bootRpc(hub)
108+
await expect(
109+
rpc.$call('example:next-devframe-hub:messages:list'),
110+
).rejects.toThrow()
111+
112+
// After exchanging the one-time code the same connection is trusted.
113+
const result = await rpc.$call('anonymous:devframe:auth:exchange', {
114+
code: getTempAuthCode(),
115+
ua: 'vitest',
116+
origin: 'http://127.0.0.1:3000',
117+
}) as { authToken: string | null }
118+
expect(result.authToken).toBeTruthy()
119+
await expect(
120+
rpc.$call('example:next-devframe-hub:messages:list'),
121+
).resolves.toBeInstanceOf(Array)
122+
})
123+
124+
it('lists startup and demo messages through the kit-local RPC', async () => {
125+
hub = await nextDevframeHub({ host: '127.0.0.1' })
126+
await hub.ready
127+
128+
const rpc = await bootTrustedRpc(hub)
89129
const messages = await rpc.$call('example:next-devframe-hub:messages:list') as { message: string }[]
90130
expect(messages.map(m => m.message)).toContain('Next Devframe Hub started')
91131
expect(messages.map(m => m.message)).toContain('Next demo devframe loaded')
@@ -95,7 +135,7 @@ describe('next-devframe-hub (example)', () => {
95135
hub = await nextDevframeHub({ host: '127.0.0.1' })
96136
await hub.ready
97137

98-
const rpc = bootRpc(hub)
138+
const rpc = await bootTrustedRpc(hub)
99139
await expect(
100140
rpc.$call('hub:commands:execute', 'example:next-devframe-hub:ping'),
101141
).resolves.toBe('pong')

examples/hub-nitro-minimal/hub.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,9 @@ export const hub: HubInstance = globalRef.__hubNitroMinimal ??= initHub({
4646
// fetches at boot and feeds into `--devframe-primary` (see
4747
// `@devframes/hub-ui`'s `primary-ramp.css`).
4848
ui: createUi({ branding: { primaryColor: '#ff2056', productName: 'Devframes on Nitro' } }),
49-
// Single-user localhost demo: reachable only on loopback, so it opts out
50-
// of the gate for a no-friction dev experience. A hub reachable beyond
51-
// localhost should gate (see docs/guide/security.md).
52-
auth: false,
49+
// Gate with devframe's interactive OTP (the default). The hub prints a
50+
// 6-digit code + magic link on startup, and the reference UI's authorization
51+
// view exchanges it for a bearer token. See docs/guide/security.md.
5352
configure(ctx) {
5453
ctx.commands.register({
5554
id: 'example:hub-nitro-minimal:ping',

examples/hub-rsbuild-minimal/rsbuild.config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,10 @@ export default defineConfig({
8989
configure(ctx) {
9090
ctx.docks.register(jsonRenderDock)
9191
},
92-
// Single-user localhost demo: opts out of the gate. A hub reachable
93-
// beyond localhost should gate (see docs/guide/security.md).
94-
auth: false,
92+
// Gate with devframe's interactive OTP (the default). The hub prints a
93+
// 6-digit code + magic link on startup, and the reference UI's
94+
// authorization view exchanges it for a bearer token. See
95+
// docs/guide/security.md.
9596
// Rsbuild's middleware stack never hands over WebSocket upgrades, so
9697
// the socket gets its own side-car port, advertised through
9798
// `__connection.json`.

examples/hub-vite-minimal/vite.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,10 @@ export default defineConfig({
9494
configure(ctx) {
9595
ctx.docks.register(jsonRenderDock)
9696
},
97-
// Single-user localhost demo: reachable only on loopback, so it opts
98-
// out of the gate. A hub reachable beyond localhost should gate (see
99-
// docs/guide/security.md).
100-
auth: false,
97+
// Gate with devframe's interactive OTP (the default). The hub prints a
98+
// 6-digit code + magic link on startup, and the reference UI's
99+
// authorization view exchanges it for a bearer token. See
100+
// docs/guide/security.md.
101101
server: httpServer,
102102
...(httpServer ? {} : { ws: { sidecar: true } }),
103103
})

0 commit comments

Comments
 (0)