import type { Ref } from 'vue' import type { AIUIRequest, ArchyResponse, AIContextCategory, ArchyContextResponse, ArchyActionResponse, } from '@/types/aiui-protocol' import { useAIPermissionsStore } from '@/stores/aiPermissions' import { useAppStore } from '@/stores/app' /** * Context Broker — mediates all communication between AIUI (iframe) and Archy. * * AIUI sends context/action requests via postMessage. * The broker checks permissions, fetches data from Pinia stores, * sanitizes it (strips sensitive fields), and responds. */ export class ContextBroker { private iframe: Ref private allowedOrigin: string private listener: ((e: MessageEvent) => void) | null = null constructor(iframe: Ref, aiuiUrl: string) { this.iframe = iframe // Extract origin from URL for security validation try { const url = new URL(aiuiUrl, window.location.origin) this.allowedOrigin = url.origin } catch { this.allowedOrigin = window.location.origin } } /** Start listening for postMessage events from AIUI */ start() { this.listener = (e: MessageEvent) => this.handleMessage(e) window.addEventListener('message', this.listener) } /** Stop listening and clean up */ stop() { if (this.listener) { window.removeEventListener('message', this.listener) this.listener = null } } /** Send permissions update to AIUI so it knows what it can ask for */ sendPermissionsUpdate() { const perms = useAIPermissionsStore() this.postToIframe({ type: 'permissions:update', categories: perms.enabledCategories, }) } /** Send theme info to AIUI */ sendTheme() { this.postToIframe({ type: 'theme:response', theme: { accent: '#fb923c', mode: 'dark', }, }) } private handleMessage(event: MessageEvent) { // Security: verify origin if (event.origin !== this.allowedOrigin) return const msg = event.data as AIUIRequest if (!msg || typeof msg.type !== 'string') return switch (msg.type) { case 'ready': this.sendPermissionsUpdate() this.sendTheme() break case 'context:request': this.handleContextRequest(msg.id, msg.category, msg.query) break case 'action:request': this.handleActionRequest(msg.id, msg.action, msg.params) break case 'theme:request': this.sendTheme() break } } private handleContextRequest(id: string, category: AIContextCategory, query?: string) { const perms = useAIPermissionsStore() if (!perms.isEnabled(category)) { this.postToIframe({ type: 'context:response', id, data: null, permitted: false, } satisfies ArchyContextResponse) return } const data = this.fetchAndSanitize(category, query) this.postToIframe({ type: 'context:response', id, data, permitted: true, } satisfies ArchyContextResponse) } private handleActionRequest(id: string, action: string, params: Record) { const appStore = useAppStore() let success = false let error: string | undefined try { switch (action) { case 'navigate': if (params.path) { window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path })) success = true } else { error = 'Missing path parameter' } break case 'open-app': if (params.appId) { window.dispatchEvent(new CustomEvent('aiui:open-app', { detail: params.appId })) success = true } else { error = 'Missing appId parameter' } break case 'install-app': if (params.appId && params.marketplaceUrl && params.version) { appStore.installPackage(params.appId, params.marketplaceUrl, params.version).then(() => { this.postToIframe({ type: 'action:response', id, success: true, } satisfies ArchyActionResponse) }).catch((err: Error) => { this.postToIframe({ type: 'action:response', id, success: false, error: err.message, } satisfies ArchyActionResponse) }) return // async — response sent in promise callbacks } error = 'Missing appId parameter' break default: error = `Unknown action: ${action}` } } catch (e) { error = e instanceof Error ? e.message : 'Unknown error' } this.postToIframe({ type: 'action:response', id, success, error, } satisfies ArchyActionResponse) } /** Fetch data from stores and strip sensitive fields */ private fetchAndSanitize(category: AIContextCategory, _query?: string): unknown { const appStore = useAppStore() switch (category) { case 'apps': return this.sanitizeApps(appStore) case 'system': return this.sanitizeSystem(appStore) case 'network': return this.sanitizeNetwork(appStore) case 'wallet': return this.sanitizeWallet(appStore) case 'files': return this.sanitizeFiles(appStore) default: return null } } private sanitizeApps(store: ReturnType): unknown { const packages = store.packages || {} return Object.entries(packages).map(([id, pkg]) => ({ id, name: pkg.manifest?.title || id, state: pkg.state || 'unknown', status: pkg.installed?.status || 'unknown', })) } private sanitizeSystem(store: ReturnType): unknown { const info = store.serverInfo if (!info) return { status: 'unavailable' } return { version: info.version, name: info.name, // Omit: hostname, IP, paths, kernel version, pubkey } } private sanitizeNetwork(store: ReturnType): unknown { return { connected: store.isConnected, // Omit: IP addresses, ports, peer details } } private sanitizeWallet(_store: ReturnType): unknown { // Wallet data requires careful handling — only expose aggregates return { available: false, message: 'Wallet context not yet implemented', // Will integrate with LND store when available } } private sanitizeFiles(_store: ReturnType): unknown { // File listing requires cloud store integration return { available: false, message: 'File context not yet implemented', // Will integrate with cloud store when available } } private postToIframe(msg: ArchyResponse) { if (!this.iframe.value?.contentWindow) return this.iframe.value.contentWindow.postMessage(msg, this.allowedOrigin) } }