archy/neode-ui/src/services/contextBroker.ts

249 lines
6.9 KiB
TypeScript
Raw Normal View History

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<HTMLIFrameElement | null>
private allowedOrigin: string
private listener: ((e: MessageEvent) => void) | null = null
constructor(iframe: Ref<HTMLIFrameElement | null>, 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<string, string>) {
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<typeof useAppStore>): 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<typeof useAppStore>): 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<typeof useAppStore>): unknown {
return {
connected: store.isConnected,
// Omit: IP addresses, ports, peer details
}
}
private sanitizeWallet(_store: ReturnType<typeof useAppStore>): 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<typeof useAppStore>): 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)
}
}