Skip to content

Commit 91291de

Browse files
committed
fix(hub): recheck trust after client script imports
1 parent 51d4100 commit 91291de

5 files changed

Lines changed: 168 additions & 2 deletions

File tree

‎packages/hub-ui/src/client/state/client-script.integration.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,81 @@ it('awaits an eager page setup before activation and retries it after failure',
211211
await expect(activation).resolves.toBe(true)
212212
expect(attempts).toBe(2)
213213
})
214+
215+
it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
216+
expect.assertions(6)
217+
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
218+
const { rpc, sharedStates: states } = createStubRpc()
219+
const context = await createDocksContext('embedded', rpc)
220+
const docks = context.docks
221+
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_UI__?: () => Promise<void>, __DF_IMPORT_SETUP_UI__?: () => void }
222+
let releaseImport!: () => void
223+
const importGate = new Promise<void>((resolve) => {
224+
releaseImport = resolve
225+
})
226+
const importing = vi.fn(() => importGate)
227+
const setup = vi.fn()
228+
fixture.__DF_IMPORT_GATE_UI__ = importing
229+
fixture.__DF_IMPORT_SETUP_UI__ = setup
230+
const entry = {
231+
id: `revoked-import-${eager}`,
232+
type: 'iframe',
233+
title: 'Revoked import',
234+
icon: 'ph:browser',
235+
url: '/fixture',
236+
clientScript: {
237+
eager,
238+
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_UI__(); export default () => globalThis.__DF_IMPORT_SETUP_UI__(); // ${eager}`,
239+
},
240+
} satisfies DevframeDockEntry
241+
try {
242+
states.get('devframe:docks')!.push([entry])
243+
const activation = docks.switchEntry(entry.id)
244+
const rejected = expect(activation).rejects.toThrow('no longer trusted')
245+
await expect.poll(() => importing.mock.calls.length).toBe(1)
246+
Object.assign(rpc, { isTrusted: false })
247+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
248+
releaseImport()
249+
await rejected
250+
expect(setup).not.toHaveBeenCalled()
251+
expect(docks.selectedId).toBeNull()
252+
Object.assign(rpc, { isTrusted: true })
253+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
254+
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
255+
expect(setup).toHaveBeenCalledOnce()
256+
}
257+
finally {
258+
releaseImport()
259+
delete fixture.__DF_IMPORT_GATE_UI__
260+
delete fixture.__DF_IMPORT_SETUP_UI__
261+
reportError.mockRestore()
262+
}
263+
})
264+
265+
it('does not activate an iframe when trust is lost while its setup completes', async () => {
266+
expect.assertions(2)
267+
const { rpc, sharedStates: states } = createStubRpc()
268+
const context = await createDocksContext('embedded', rpc)
269+
const docks = context.docks
270+
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_UI__?: () => void }
271+
fixture.__DF_SETUP_REVOKE_UI__ = () => {
272+
Object.assign(rpc, { isTrusted: false })
273+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
274+
}
275+
const entry = {
276+
id: 'revoked-during-setup',
277+
type: 'iframe',
278+
title: 'Revoked setup',
279+
icon: 'ph:browser',
280+
url: '/fixture',
281+
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_UI__()' },
282+
} satisfies DevframeDockEntry
283+
try {
284+
states.get('devframe:docks')!.push([entry])
285+
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
286+
expect(docks.selectedId).toBeNull()
287+
}
288+
finally {
289+
delete fixture.__DF_SETUP_REVOKE_UI__
290+
}
291+
})

‎packages/hub-ui/src/client/state/context.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,8 @@ export async function createDocksContext(
309309
if (!rpc.isTrusted)
310310
return false
311311
await runPageScript(entry)
312+
if (!rpc.isTrusted)
313+
return false
312314

313315
initialRestorePending.value = false
314316
selectedDockId.value = entry.id

‎packages/hub-ui/src/client/state/setup-script.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ async function _executeSetupScript(
3434
const fn = mod[script.importName ?? 'default']
3535
if (typeof fn !== 'function')
3636
throw new Error(`[@devframes/hub-ui] "${specifier}" exports no callable "${script.importName ?? 'default'}"`)
37+
/** Trust may change while the module is loading; rejection keeps setup retryable. */
38+
if (!context.rpc.isTrusted)
39+
throw new Error('[@devframes/hub-ui] RPC client is no longer trusted')
3740
await fn(context)
3841
}
3942
catch (error) {

‎packages/hub/src/client/__tests__/host.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,83 @@ it('waits for trust for eager setup and activation for lazy setup in the headles
454454
delete fixture.__DF_LAZY_TEST__
455455
}
456456
})
457+
458+
it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
459+
expect.assertions(6)
460+
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
461+
const { rpc, states } = createStubRpc()
462+
const runtime = await createDevframeClientRuntime({ rpc })
463+
const docks = runtime.context.docks
464+
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_HEADLESS__?: () => Promise<void>, __DF_IMPORT_SETUP_HEADLESS__?: () => void }
465+
let releaseImport!: () => void
466+
const importGate = new Promise<void>((resolve) => {
467+
releaseImport = resolve
468+
})
469+
const importing = vi.fn(() => importGate)
470+
const setup = vi.fn()
471+
fixture.__DF_IMPORT_GATE_HEADLESS__ = importing
472+
fixture.__DF_IMPORT_SETUP_HEADLESS__ = setup
473+
const entry = {
474+
id: `revoked-import-${eager}`,
475+
type: 'iframe',
476+
title: 'Revoked import',
477+
icon: 'ph:browser',
478+
url: '/fixture',
479+
clientScript: {
480+
eager,
481+
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_HEADLESS__(); export default () => globalThis.__DF_IMPORT_SETUP_HEADLESS__(); // ${eager}`,
482+
},
483+
} satisfies DevframeDockEntry
484+
try {
485+
states.get('devframe:docks')!.push([entry])
486+
const activation = docks.switchEntry(entry.id)
487+
const rejected = expect(activation).rejects.toThrow('no longer trusted')
488+
await expect.poll(() => importing.mock.calls.length).toBe(1)
489+
Object.assign(rpc, { isTrusted: false })
490+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
491+
releaseImport()
492+
await rejected
493+
expect(setup).not.toHaveBeenCalled()
494+
expect(docks.selectedId).toBeNull()
495+
Object.assign(rpc, { isTrusted: true })
496+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
497+
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
498+
expect(setup).toHaveBeenCalledOnce()
499+
}
500+
finally {
501+
releaseImport()
502+
delete fixture.__DF_IMPORT_GATE_HEADLESS__
503+
delete fixture.__DF_IMPORT_SETUP_HEADLESS__
504+
reportError.mockRestore()
505+
runtime.dispose()
506+
}
507+
})
508+
509+
it('does not activate an iframe when trust is lost while its setup completes', async () => {
510+
expect.assertions(2)
511+
const { rpc, states } = createStubRpc()
512+
const runtime = await createDevframeClientRuntime({ rpc })
513+
const docks = runtime.context.docks
514+
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_HEADLESS__?: () => void }
515+
fixture.__DF_SETUP_REVOKE_HEADLESS__ = () => {
516+
Object.assign(rpc, { isTrusted: false })
517+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
518+
}
519+
const entry = {
520+
id: 'revoked-during-setup',
521+
type: 'iframe',
522+
title: 'Revoked setup',
523+
icon: 'ph:browser',
524+
url: '/fixture',
525+
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_HEADLESS__()' },
526+
} satisfies DevframeDockEntry
527+
try {
528+
states.get('devframe:docks')!.push([entry])
529+
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
530+
expect(docks.selectedId).toBeNull()
531+
}
532+
finally {
533+
delete fixture.__DF_SETUP_REVOKE_HEADLESS__
534+
runtime.dispose()
535+
}
536+
})

‎packages/hub/src/client/host.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ export async function createDevframeClientRuntime(
426426
return false
427427
if (entry.type === 'iframe' && entry.clientScript)
428428
await setupClientScript(entry.id, entry.clientScript)
429-
return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry
429+
return !disposed && rpc.isTrusted && entryToStateMap.get(entry.id)?.entryMeta === entry
430430
}
431431

432432
async function runActivationScript(entry: DevframeDockEntry): Promise<void> {
@@ -606,8 +606,11 @@ export async function createDevframeClientRuntime(
606606
if (typeof fn !== 'function')
607607
throw new Error(`[@devframes/hub] "${specifier}" exports no callable "${script.importName ?? 'default'}"`)
608608
const current = entryToStateMap.get(entryId)
609-
if (!current || disposed || !rpc.isTrusted)
609+
if (!current || disposed)
610610
return
611+
/** Reject instead of caching skipped setup so re-authentication can retry it. */
612+
if (!rpc.isTrusted)
613+
throw new Error('[@devframes/hub] RPC client is no longer trusted')
611614
// Scope the messages client to this entry: its messages default their
612615
// `category` to the entry id, so the feed can attribute and group them.
613616
const messages = createMessagesClient(rpc, { defaults: { category: entryId } })

0 commit comments

Comments
 (0)