diff --git a/docs/app-media-integration.md b/docs/app-media-integration.md
index 8407a553..256685f6 100644
--- a/docs/app-media-integration.md
+++ b/docs/app-media-integration.md
@@ -36,6 +36,29 @@ Audio controls do not require Nostr signing. Apps needing Nostr login must follo
selection, scoped consent, server-verified authentication and cancellation. Never
put signer permissions, wallet operations or signing keys into the media bridge.
+## Public adapter and independent example
+
+[`examples/audio-app`](../examples/audio-app/) contains an independently authored
+adapter and a small player using files the user selects locally. It includes no
+V4V code, assets, recordings or authentication implementation. Serve the example
+as an authenticated app; configure the exact dashboard origin in its deployment
+owned `dashboard-origin` metadata. Do not derive trust from a query parameter.
+
+Import `attachAudioBridge` from `archipelago-audio.mjs` and supply your actual
+player's `snapshot`, `actions` and `unlocked` callbacks. Call `publish()` when
+playback changes and `dispose()` when the player is destroyed. The sample uses
+one queue for both in-app and dashboard actions, and never passes stream URLs or
+credentials to the host. Its Node tests run with
+`node --test examples/audio-app/archipelago-audio.test.mjs`.
+
+The generic eligibility change is under qualification: new sessions require the
+authenticated daemon's verified catalog, matching app identity and installed
+version, plus an unambiguous iframe-compatible manifest. Community storefront
+fallbacks cannot grant this integration. An already admitted session retains its
+original frame/origin/nonce solely so its existing controls and cleanup remain
+usable if catalog freshness expires. App removal, version or frame replacement
+releases that admission; a new session must qualify again.
+
## Keep a single player and queue
The app continues to own its audio element, queue, authorization and playback
diff --git a/examples/audio-app/archipelago-audio.mjs b/examples/audio-app/archipelago-audio.mjs
new file mode 100644
index 00000000..e5440dd8
--- /dev/null
+++ b/examples/audio-app/archipelago-audio.mjs
@@ -0,0 +1,58 @@
+// Independently authored reference adapter for the public archipelago-v1 protocol.
+// No application authentication, signing keys or stream URLs cross this bridge.
+export function attachAudioBridge({ parentOrigin, snapshot, actions, unlocked = () => true, host = window }) {
+ const parsed = new URL(parentOrigin)
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== parentOrigin || parsed.username || parsed.password) throw new Error('Configure an exact trusted dashboard origin')
+ if (host.parent === host) return { publish() {}, dispose() {} }
+ let session = null, closed = false, queue = Promise.resolve()
+ const send = data => host.parent.postMessage({ version: 1, ...data }, parentOrigin)
+ function publish() {
+ if (closed || !session) return
+ const state = unlocked() ? snapshot() : null
+ if (!state || typeof state.title !== 'string' || typeof state.artist !== 'string'
+ || typeof state.playing !== 'boolean' || typeof state.shuffle !== 'boolean'
+ || !Number.isFinite(state.position) || !Number.isFinite(state.duration) || state.position < 0 || state.duration < 0) {
+ send({type:'archipelago:media-state',session,available:false}); return
+ }
+ send({type:'archipelago:media-state',session,available:true,title:state.title.slice(0,256),artist:state.artist.slice(0,256),
+ playing:state.playing,shuffle:state.shuffle,position:Math.min(state.position,state.duration),duration:state.duration,
+ artwork:typeof state.artwork === 'string' ? state.artwork.slice(0,2048) : ''})
+ }
+ function message(event) {
+ if (closed || event.source !== host.parent || event.origin !== parentOrigin || event.data?.version !== 1) return
+ const data = event.data
+ if (data.type === 'archipelago:media-connect') {
+ if (typeof data.session !== 'string' || !/^[0-9a-f]{32}$/.test(data.session)) return
+ session = data.session; publish(); return
+ }
+ if (data.type !== 'archipelago:media-control' || !session || data.session !== session || !unlocked()) return
+ if (!['play','pause','seek','next','previous','shuffle','snapshot'].includes(data.command)) return
+ if (data.command === 'seek' && (!Number.isFinite(data.position) || data.position < 0)) return
+ const expected = session
+ queue = queue.catch(() => {}).then(async () => {
+ if (closed || session !== expected || !unlocked()) return
+ if (data.command !== 'snapshot') {
+ const action = actions[data.command]
+ if (typeof action !== 'function') return
+ const state = snapshot()
+ if (data.command === 'seek') {
+ if (!Number.isFinite(state?.duration) || state.duration < 0) return
+ await action(Math.min(data.position, state.duration))
+ } else await action()
+ }
+ if (!closed && session === expected) publish()
+ }).catch(() => { if (!closed && session === expected) publish() })
+ }
+ const ready = () => { if (!closed) send({type:'archipelago:media-ready'}) }
+ function dispose() {
+ if (closed) return
+ if (session) send({type:'archipelago:media-state',session,available:false})
+ closed = true; session = null
+ host.removeEventListener('message',message); host.removeEventListener('pageshow',ready); host.removeEventListener('pagehide',leave)
+ actions.pause?.()
+ }
+ function leave(event) { if (!event.persisted) dispose() }
+ host.addEventListener('message',message); host.addEventListener('pageshow',ready); host.addEventListener('pagehide',leave)
+ ready()
+ return { publish, dispose }
+}
diff --git a/examples/audio-app/archipelago-audio.test.mjs b/examples/audio-app/archipelago-audio.test.mjs
new file mode 100644
index 00000000..4cb4ef92
--- /dev/null
+++ b/examples/audio-app/archipelago-audio.test.mjs
@@ -0,0 +1,38 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { attachAudioBridge } from './archipelago-audio.mjs'
+function fixture() {
+ const sent=[], handlers=new Map(), calls=[]
+ const parent={postMessage:(data,origin)=>sent.push({data,origin})}
+ const host={parent,addEventListener:(name,fn)=>handlers.set(name,fn),removeEventListener:name=>handlers.delete(name)}
+ let unlocked=true
+ const state={title:'Own track',artist:'Artist',playing:true,shuffle:false,position:4,duration:30}
+ const actions=Object.fromEntries(['play','pause','next','previous','shuffle','seek'].map(name=>[name,async value=>calls.push([name,value])]))
+ const bridge=attachAudioBridge({parentOrigin:'https://node.test',host,snapshot:()=>state,unlocked:()=>unlocked,actions})
+ const receive=(data,origin='https://node.test',source=parent)=>handlers.get('message')?.({source,origin,data:{version:1,...data}})
+ return {sent,handlers,calls,state,bridge,receive,lock:()=>{unlocked=false}}
+}
+test('validates parent and nonce; delegates real controls with bounded seeks',async()=>{
+ const f=fixture(),session='a'.repeat(32)
+ f.receive({type:'archipelago:media-connect',session},'https://foreign.test')
+ assert.equal(f.sent.length,1)
+ f.receive({type:'archipelago:media-connect',session})
+ f.receive({type:'archipelago:media-control',session:'b'.repeat(32),command:'play'})
+ f.receive({type:'archipelago:media-control',session,command:'seek',position:99})
+ f.receive({type:'archipelago:media-control',session,command:'previous'})
+ await new Promise(setImmediate)
+ assert.deepEqual(f.calls,[['seek',30],['previous',undefined]])
+ assert(f.sent.every(message=>message.origin==='https://node.test'))
+ f.lock();f.bridge.publish();assert.equal(f.sent.at(-1).data.available,false)
+ assert.equal('title' in f.sent.at(-1).data,false)
+ f.bridge.dispose();assert.equal(f.handlers.size,0)
+})
+test('stale queued commands do not control a replacement handshake',async()=>{
+ const f=fixture(),session='a'.repeat(32)
+ f.receive({type:'archipelago:media-connect',session})
+ f.receive({type:'archipelago:media-control',session,command:'next'})
+ f.receive({type:'archipelago:media-connect',session:'b'.repeat(32)})
+ await new Promise(setImmediate)
+ assert.deepEqual(f.calls,[])
+ f.bridge.dispose()
+})
diff --git a/examples/audio-app/index.html b/examples/audio-app/index.html
new file mode 100644
index 00000000..c263f27b
--- /dev/null
+++ b/examples/audio-app/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
Public audio bridge example
+
+Audio bridge example
Select your own audio files. This example uploads nothing and contains no demo catalog, private app source or bundled recordings.
+
+No track selected
+
+
diff --git a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts
index c49860c0..62e6db59 100644
--- a/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts
+++ b/neode-ui/src/composables/__tests__/useAppMediaBridge.test.ts
@@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({
player: { updateExternal: vi.fn(), detachExternal: vi.fn(), releaseExternal: vi.fn(), setExternalVisible: vi.fn() },
launcher: { mediaAppId: 'node-demo-v4v' as string | null, panelAppId: null as string | null, openSession: vi.fn() },
}))
+vi.mock('@/stores/app', () => ({useAppStore:()=>({data:{'package-data':{'node-demo-v4v':{manifest:{version:'1'}}}}})}))
vi.mock('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
@@ -49,3 +50,15 @@ describe('app media session boundary', () => {
expect(child.postMessage.mock.calls[child.postMessage.mock.calls.length - 1]![0]).toMatchObject({ command: 'pause' })
})
})
+
+it('retains controls and cleanup after catalog expiry but does not admit a new session',()=>{
+ const child={postMessage:vi.fn()};let bridge!:ReturnType
+ const wrapper=mount(defineComponent({setup(){bridge=useAppMediaBridge(ref('node-demo-v4v'),ref('https://node.test:7475/'),shallowRef({contentWindow:child} as unknown as HTMLIFrameElement),ref(false));return()=>null}}))
+ fixture.allowed=true;bridge.connect();const session=child.postMessage.mock.calls[0]![0].session
+ fixture.allowed=false
+ bridge.handle({source:child,origin:'https://node.test:7475',data:{type:'archipelago:media-state',version:1,session,available:true,title:'Song',artist:'Artist',playing:true,position:1,duration:10}} as unknown as MessageEvent)
+ const controller=fixture.player.updateExternal.mock.lastCall![0];controller.pause();expect(child.postMessage.mock.lastCall![0].command).toBe('pause')
+ wrapper.unmount();expect(child.postMessage.mock.lastCall![0].command).toBe('pause')
+ const outsider={postMessage:vi.fn()};const denied=mount(defineComponent({setup(){const b=useAppMediaBridge(ref('node-demo-v4v'),ref('https://node.test:7475/'),shallowRef({contentWindow:outsider} as unknown as HTMLIFrameElement),ref(false));b.connect();return()=>null}}))
+ expect(outsider.postMessage).not.toHaveBeenCalled();denied.unmount()
+})
diff --git a/neode-ui/src/composables/useAppMediaBridge.ts b/neode-ui/src/composables/useAppMediaBridge.ts
index 370ffe2e..d250b2de 100644
--- a/neode-ui/src/composables/useAppMediaBridge.ts
+++ b/neode-ui/src/composables/useAppMediaBridge.ts
@@ -1,21 +1,41 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
import { appHasMediaBridge } from '@/views/discover/curatedApps'
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
+import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
export function useAppMediaBridge(appId: Ref, url: Ref, frame: Ref, visible: Ref) {
const player = useAudioPlayer()
const launcher = useAppLauncherStore()
+ const app = useAppStore()
+ const installedVersion = () => app.data?.['package-data']?.[appId.value]?.manifest?.version
+ let admitted: { source: Window; origin: string; appId: string; version: string } | null = null
const session = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
const id = `${appId.value}:${session}`
const origin = () => { try { return new URL(url.value, window.location.origin).origin } catch { return '' } }
+ function matchesAdmission() {
+ return admitted !== null && admitted.source === frame.value?.contentWindow && admitted.origin === origin()
+ && admitted.appId === appId.value && admitted.version === installedVersion()
+ }
+ function release() {
+ // Catalog expiry/removal cannot revoke our ability to stop an already
+ // admitted session. The old nonce/source/origin still constrain cleanup.
+ admitted?.source.postMessage({type:'archipelago:media-control',version:1,session,command:'pause'}, admitted.origin)
+ admitted = null
+ player.releaseExternal(id)
+ }
function command(command: string, position?: number) {
- if (!appHasMediaBridge(appId.value) || !origin()) return
- frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, origin())
+ if (!matchesAdmission()) return
+ admitted!.source.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, admitted!.origin)
}
function connect() {
- if (!appHasMediaBridge(appId.value) || !origin()) return
- frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
+ if (admitted && !matchesAdmission()) release()
+ if (!admitted) {
+ const version = installedVersion(), source = frame.value?.contentWindow
+ if (!version || !source || !origin() || !appHasMediaBridge(appId.value, version)) return
+ admitted = {source,origin:origin(),appId:appId.value,version}
+ }
+ admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin)
}
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
@@ -24,11 +44,11 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R
},
}
function handle(event: MessageEvent) {
- if (!appHasMediaBridge(appId.value) || event.source !== frame.value?.contentWindow || event.origin !== origin()) return
+ if (event.source !== frame.value?.contentWindow || event.origin !== origin()) return
const data = event.data
if (data?.version !== 1) return
if (data.type === 'archipelago:media-ready') { connect(); return }
- if (data.type !== 'archipelago:media-state' || data.session !== session) return
+ if (!matchesAdmission() || data.type !== 'archipelago:media-state' || data.session !== session) return
if (data.available !== true) { player.detachExternal(id); return }
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|| !Number.isFinite(data.position) || !Number.isFinite(data.duration) || data.position < 0 || data.duration < 0) return
@@ -44,6 +64,7 @@ export function useAppMediaBridge(appId: Ref, url: Ref, frame: R
player.updateExternal(controller, state, visible.value)
}
watch(visible, shown => player.setExternalVisible(id, shown))
- onBeforeUnmount(() => { command('pause'); player.releaseExternal(id) })
+ watch([appId, url, frame, installedVersion], () => { if (admitted && !matchesAdmission()) release() }, {flush:'sync'})
+ onBeforeUnmount(release)
return { connect, handle }
}
diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts
index 8f456acc..cb9b8b93 100644
--- a/neode-ui/src/stores/appLauncher.ts
+++ b/neode-ui/src/stores/appLauncher.ts
@@ -1,4 +1,4 @@
-import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
+import { appHasMediaBridge, appRequiresHostFrame, appLaunchPolicyLoaded, ensureAppLaunchPolicy, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
import { defineStore } from 'pinia'
import { ref, watch, onScopeDispose } from 'vue'
import { rpcClient } from '@/api/rpc-client'
@@ -237,7 +237,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
- if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appHasMediaBridge(appId)) {
+ if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appRequiresHostFrame(appId, pkg?.manifest.version) && !appHasMediaBridge(appId, pkg?.manifest.version)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -274,7 +274,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from.
- if (appHasMediaBridge(appId)) mediaAppId.value = appId
+ if (pkg && appHasMediaBridge(appId, pkg.manifest.version)) mediaAppId.value = appId
panelPath.value = opts.path ?? null
panelAppId.value = appId
}
@@ -282,7 +282,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
* Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */
- function openSession(appId: string, opts: LaunchOptions = {}) {
+ function openSession(appId: string, opts: LaunchOptions = {}, checkedPolicy = false) {
const generation = ++launchGeneration
if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) {
useToast().info('Loading this node’s demo app…')
@@ -296,6 +296,10 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
})
return
}
+ if (!IS_DEMO && !appId.startsWith('node-demo-') && !checkedPolicy && !appLaunchPolicyLoaded()) {
+ void ensureAppLaunchPolicy().then(() => { if (generation === launchGeneration) openSession(appId, opts, true) })
+ return
+ }
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
// Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user.
diff --git a/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts b/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts
new file mode 100644
index 00000000..1078d3b0
--- /dev/null
+++ b/neode-ui/src/views/discover/__tests__/genericMediaPolicy.test.ts
@@ -0,0 +1,19 @@
+import {afterEach,describe,expect,it,vi} from 'vitest'
+import {__setSignedCatalogForTests,appHasMediaBridge,appRequiresHostFrame,ensureAppLaunchPolicy} from '../curatedApps'
+afterEach(()=>{__setSignedCatalogForTests(null);vi.unstubAllGlobals()})
+describe('generic manifest media eligibility',()=>{
+ it('accepts a verified ordinary catalog app at its installed version',()=>{
+ __setSignedCatalogForTests({apps:{'public-audio':{version:'2',manifest:{app:{id:'public-audio',metadata:{launch:{media_controls:'archipelago-v1',requires_host_frame:true}}}}}}})
+ expect(appHasMediaBridge('public-audio','2')).toBe(true);expect(appRequiresHostFrame('public-audio','2')).toBe(true)
+ expect(appHasMediaBridge('public-audio','1')).toBe(false)
+ })
+ it('does not grant a public catalog authority over node-only demos or conflicting launch policy',()=>{
+ __setSignedCatalogForTests({apps:{'node-demo-injected':{version:'1',manifest:{app:{id:'node-demo-injected',metadata:{launch:{media_controls:'archipelago-v1'}}}}},bad:{version:'1',manifest:{app:{id:'bad',metadata:{launch:{media_controls:'archipelago-v1',open_in_new_tab:true}}}}}}})
+ expect(appHasMediaBridge('node-demo-injected','1')).toBe(false);expect(appHasMediaBridge('bad','1')).toBe(false)
+ })
+ it('loads only the authenticated verified endpoint before first launch',async()=>{
+ const fetcher=vi.fn(async(_url:string,_init?:RequestInit)=>({ok:true,json:async()=>({apps:{music:{version:'1',manifest:{app:{id:'music',metadata:{launch:{media_controls:'archipelago-v1'}}}}}}})}))
+ vi.stubGlobal('fetch',fetcher);await Promise.all([ensureAppLaunchPolicy(),ensureAppLaunchPolicy()])
+ expect(fetcher).toHaveBeenCalledTimes(1);expect(fetcher.mock.calls[0]?.[0]).toBe('/api/app-catalog');expect(appHasMediaBridge('music','1')).toBe(true)
+ })
+})
diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts
index 7c3d9d48..7f73b134 100644
--- a/neode-ui/src/views/discover/curatedApps.ts
+++ b/neode-ui/src/views/discover/curatedApps.ts
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
description?: string
category?: string
container?: { image?: string }
- metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
+ metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string; requires_host_frame?: boolean; open_in_new_tab?: boolean } }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
}
@@ -110,8 +110,41 @@ let nodeCatalogRequest: Promise | null = null
export function nodeAppIsAvailable(id: string): boolean {
return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id])
}
-export function appHasMediaBridge(id: string): boolean {
- return nodeAppIsAvailable(id) && nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
+function verifiedLaunchEntry(id: string): SignedAppEntry | undefined {
+ // Node-only app names never acquire authority from the public catalog.
+ const entry = id.startsWith('node-demo-')
+ ? (nodeAppIsAvailable(id) ? nodeCatalogCache?.apps[id] : undefined)
+ : signedCatalogCache?.apps[id]
+ return entry?.manifest?.app?.id === id ? entry : undefined
+}
+export function appHasMediaBridge(id: string, installedVersion?: string): boolean {
+ const entry = verifiedLaunchEntry(id)
+ if (!entry || (installedVersion !== undefined && entry.version !== installedVersion)) return false
+ const launch = entry.manifest?.app?.metadata?.launch
+ return launch?.media_controls === 'archipelago-v1' && launch.open_in_new_tab !== true
+}
+export function appRequiresHostFrame(id: string, installedVersion?: string): boolean {
+ const entry = verifiedLaunchEntry(id)
+ return Boolean(entry && (installedVersion === undefined || entry.version === installedVersion)
+ && entry.manifest?.app?.metadata?.launch?.requires_host_frame === true
+ && entry.manifest.app.metadata.launch.open_in_new_tab !== true)
+}
+let launchPolicyRequest: Promise | null = null
+export function appLaunchPolicyLoaded(): boolean { return signedCatalogCache !== null }
+/** Read only the authenticated daemon-verified catalog; community fallback
+ * metadata must never grant a native integration. */
+export async function ensureAppLaunchPolicy(): Promise {
+ if (signedCatalogCache) return
+ if (!launchPolicyRequest) launchPolicyRequest = (async () => {
+ try {
+ const response = await fetch('/api/app-catalog', {credentials:'include',signal:AbortSignal.timeout(5000)})
+ if (!response.ok) return
+ const value = await response.json() as SignedAppCatalog
+ if (value.apps && !Array.isArray(value.apps)) signedCatalogCache = value
+ } catch { /* Unavailable policy cannot authorize a new integration. */ }
+ finally { launchPolicyRequest = null }
+ })()
+ await launchPolicyRequest
}
/** Resolve node-owned launch policy independently of the public storefront.