Skip to content

Commit c53bbc8

Browse files
antfubotopencode
andcommitted
fix(client): gate outbound calls behind the auth bootstrap handshake
connectDevframe()/getDevframeRpcClient() kicks off the auth bootstrap (stored token, then the URL's magic-link OTP, then a native-prompt fallback) without waiting for it, so it can return the client right away. A caller's very first RPC calls — e.g. a component's onMount, fired the instant connectDevframe() resolves — raced that still in-flight sequence: the socket was already open, so the transport sent them straight through, and the server rejected them with DF0036 because trust hadn't landed yet, even though the exact same call would succeed a moment later once the handshake completed. rpc.call/callOptional/callEvent now hold anything issued before the first bootstrap attempt settles and release it (or fail fast, if the handshake ultimately failed) once it does. This lives once in devframe's client RPC core, so every plugin's standalone/hosted SPA gets it automatically — no plugin-level changes needed. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 9ed9874 commit c53bbc8

4 files changed

Lines changed: 179 additions & 4 deletions

File tree

docs/guide/client.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ if (!trusted) {
8080
}
8181
```
8282

83+
`connectDevframe()` kicks off that same handshake (stored token, then a magic-link OTP on the page URL, then — top-level pages only — a native prompt) the moment it resolves, without making the caller wait for it. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` know about that in-flight handshake internally and hold anything issued before it settles, so application code can call a trusted method right away — e.g. in a component's `onMount` — without an explicit `ensureTrusted()` guard just to avoid a race. Reach for `ensureTrusted()` when you want to reflect the pending state in the UI (a spinner, a "waiting for auth" banner), not to make calls safe.
84+
8385
### Authenticating with a one-time code
8486

8587
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:

docs/guide/security.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Exactly one rule decides what an untrusted connection may call: **a method is re
2424

2525
`startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036).
2626

27+
On the client, `connectDevframe()` kicks off the handshake below without waiting for it, so a naive client could otherwise race it — sending a trusted call over the freshly-opened socket before the server has had a chance to answer `anonymous:devframe:auth`, hitting this exact gate. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued while that first handshake is still in flight and release it once the handshake settles, so application code never has to special-case this window itself.
28+
2729
## Authentication flow
2830

2931
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.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { ConnectionMeta } from 'devframe/types'
2+
import type { DevframeRpcClientMode } from './rpc'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
5+
// `getDevframeRpcClient` kicks off the auth bootstrap (`requestTrust`, then
6+
// the URL OTP, then a native-prompt fallback) without awaiting it, so it can
7+
// return the client immediately. The regression this guards is a caller's
8+
// very first `rpc.call(...)` — issued the moment `connectDevframe()` resolves
9+
// — racing that still-in-flight bootstrap: without a gate, the transport
10+
// sends the call over an already-open-but-not-yet-trusted socket and the
11+
// server rejects it with DF0036, even though the exact same call succeeds a
12+
// moment later once the handshake lands.
13+
//
14+
// Exercising this through a real WebSocket would mean hand-rolling birpc's
15+
// wire protocol, so instead this mocks `./rpc-ws` with a controllable fake
16+
// mode — a `requestTrust()` the test resolves on its own schedule, and a
17+
// `call` spy whose invocation timing is exactly what's under test.
18+
const fakeMode = vi.hoisted(() => {
19+
const requestTrustGate = { resolve: undefined as ((value: boolean) => void) | undefined }
20+
const call = vi.fn(async () => 'ok')
21+
const callOptional = vi.fn(async () => 'ok')
22+
const callEvent = vi.fn(async () => {})
23+
return { requestTrustGate, call, callOptional, callEvent }
24+
})
25+
26+
vi.mock('./rpc-ws', () => ({
27+
createWsRpcClientMode: vi.fn((): DevframeRpcClientMode => ({
28+
isTrusted: false,
29+
status: 'connecting',
30+
connectionError: null,
31+
ensureTrusted: async () => true,
32+
requestTrust: () => new Promise<boolean>((resolve) => {
33+
fakeMode.requestTrustGate.resolve = resolve
34+
}),
35+
requestTrustWithToken: async () => true,
36+
requestTrustWithCode: async () => null,
37+
call: fakeMode.call as DevframeRpcClientMode['call'],
38+
callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'],
39+
callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'],
40+
})),
41+
}))
42+
43+
class FakeBroadcastChannel {
44+
onmessage: ((e: any) => void) | null = null
45+
postMessage(): void {}
46+
close(): void {}
47+
}
48+
49+
const connectionMeta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } }
50+
51+
describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => {
52+
beforeEach(() => {
53+
fakeMode.requestTrustGate.resolve = undefined
54+
fakeMode.call.mockClear()
55+
fakeMode.callOptional.mockClear()
56+
fakeMode.callEvent.mockClear()
57+
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
58+
vi.stubGlobal('navigator', { userAgent: 'test' })
59+
vi.stubGlobal('location', {
60+
protocol: 'http:',
61+
host: 'localhost:5173',
62+
hostname: 'localhost',
63+
href: 'http://localhost:5173/index.html',
64+
origin: 'http://localhost:5173',
65+
})
66+
})
67+
68+
afterEach(() => {
69+
vi.unstubAllGlobals()
70+
})
71+
72+
it('holds `call` until the in-flight bootstrap settles, instead of sending it early', async () => {
73+
const { getDevframeRpcClient } = await import('./rpc')
74+
const rpc = await getDevframeRpcClient({
75+
connectionMeta,
76+
otpParam: false,
77+
simpleAuth: false,
78+
})
79+
80+
// Fired the instant the client is available — mirrors a component's
81+
// `onMount` calling a trusted method right after `connectDevframe()`.
82+
const pending = rpc.call('test:probe' as any)
83+
await Promise.resolve()
84+
await Promise.resolve()
85+
// The bootstrap's `requestTrust()` hasn't resolved yet — the call must
86+
// not have reached the (mocked) transport.
87+
expect(fakeMode.call).not.toHaveBeenCalled()
88+
89+
// The handshake lands.
90+
fakeMode.requestTrustGate.resolve?.(true)
91+
92+
await expect(pending).resolves.toBe('ok')
93+
expect(fakeMode.call).toHaveBeenCalledTimes(1)
94+
expect(fakeMode.call).toHaveBeenCalledWith('test:probe')
95+
})
96+
97+
it('holds `callOptional` and `callEvent` the same way', async () => {
98+
const { getDevframeRpcClient } = await import('./rpc')
99+
const rpc = await getDevframeRpcClient({
100+
connectionMeta,
101+
otpParam: false,
102+
simpleAuth: false,
103+
})
104+
105+
const pendingOptional = rpc.callOptional('test:optional' as any)
106+
const pendingEvent = rpc.callEvent('test:event' as any)
107+
await Promise.resolve()
108+
await Promise.resolve()
109+
expect(fakeMode.callOptional).not.toHaveBeenCalled()
110+
expect(fakeMode.callEvent).not.toHaveBeenCalled()
111+
112+
fakeMode.requestTrustGate.resolve?.(true)
113+
114+
await expect(pendingOptional).resolves.toBe('ok')
115+
await pendingEvent
116+
expect(fakeMode.callOptional).toHaveBeenCalledTimes(1)
117+
expect(fakeMode.callEvent).toHaveBeenCalledTimes(1)
118+
})
119+
120+
it('stops gating once the first bootstrap attempt has settled', async () => {
121+
const { getDevframeRpcClient } = await import('./rpc')
122+
const rpc = await getDevframeRpcClient({
123+
connectionMeta,
124+
otpParam: false,
125+
simpleAuth: false,
126+
})
127+
128+
fakeMode.requestTrustGate.resolve?.(true)
129+
// Let the bootstrap's own promise chain fully settle before the next
130+
// call — a few microtask ticks cover `await mode.requestTrust()`
131+
// resuming, `bootstrapAuth()` returning, and its `.then()` flipping
132+
// `bootstrapAuthSettled`.
133+
for (let i = 0; i < 5; i++)
134+
await Promise.resolve()
135+
136+
await rpc.call('test:probe' as any)
137+
// Sent straight through — no more waiting once bootstrap is over.
138+
expect(fakeMode.call).toHaveBeenCalledTimes(1)
139+
})
140+
})

packages/devframe/src/client/rpc.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,30 @@ export async function getDevframeRpcClient(
408408
}
409409
catch {}
410410

411+
// Gate outbound calls behind the auth bootstrap kicked off below (stored
412+
// auth token, then the URL's magic-link OTP, then — standalone/top-level
413+
// only — a native prompt). Without this, a caller's very first RPC calls
414+
// (fired the moment `connectDevframe()` resolves — e.g. a component's
415+
// `onMount`) race that still-in-flight sequence: the socket is open, so
416+
// the transport happily sends them, and the server rejects them with
417+
// DF0036 because trust hasn't landed yet — even though the exact same call
418+
// would succeed a moment later. `bootstrapAuthPromise` is assigned once
419+
// `bootstrapAuth()` is kicked off further down; these closures read the
420+
// variable at call time, so the gate is live even though it's declared
421+
// before that assignment happens. Once the first bootstrap attempt has
422+
// settled (trusted, refused, or given up), `bootstrapAuthSettled` flips
423+
// for good and every later call skips the gate entirely — this only ever
424+
// holds the first wave of calls.
425+
let bootstrapAuthPromise: Promise<void> | undefined
426+
let bootstrapAuthSettled = false
427+
function gateOnBootstrapAuth<F extends (...args: any[]) => any>(fn: F): F {
428+
return ((...args: any[]) => {
429+
if (bootstrapAuthSettled || !bootstrapAuthPromise)
430+
return fn(...args)
431+
return bootstrapAuthPromise.then(() => fn(...args))
432+
}) as F
433+
}
434+
411435
const rpc: DevframeRpcClient = {
412436
events,
413437
get isTrusted() {
@@ -440,9 +464,9 @@ export async function getDevframeRpcClient(
440464
catch {}
441465
return true
442466
},
443-
call: mode.call,
444-
callEvent: mode.callEvent,
445-
callOptional: mode.callOptional,
467+
call: gateOnBootstrapAuth(mode.call),
468+
callEvent: gateOnBootstrapAuth(mode.callEvent),
469+
callOptional: gateOnBootstrapAuth(mode.callOptional),
446470
client: clientRpc,
447471
sharedState: undefined!,
448472
streaming: undefined!,
@@ -522,7 +546,14 @@ export async function getDevframeRpcClient(
522546
return
523547
await runSimpleAuthPrompt()
524548
}
525-
void bootstrapAuth()
549+
// Always resolves (never rejects) regardless of `bootstrapAuth`'s outcome —
550+
// the gate only cares that the first attempt is *over*, not whether it
551+
// succeeded; a rejection here must never leak into unrelated calls waiting
552+
// on `bootstrapAuthPromise`.
553+
bootstrapAuthPromise = bootstrapAuth().then(
554+
() => { bootstrapAuthSettled = true },
555+
() => { bootstrapAuthSettled = true },
556+
)
526557

527558
// Listen for auth updates from other tabs (e.g., the auth page, or another
528559
// tab that just completed a code exchange).

0 commit comments

Comments
 (0)