Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA is no longer embedded (web/embed.go deleted); it's a standalone static build served separately (make ui / make deploy-ui). The api process adds CORS and drops the dev-open auth bypass — every route now needs a real bearer token, including SSE (?token= query param, EventSource can't set headers) and api's own /agent proxy to nomos (previously unauthenticated by omission). nomos was an unauthenticated client of api's /mcp and approval-decision endpoints; closing dev-open would have broken it, so it now sends Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api. SPA gets a runtime config module (config.ts) and a Config.svelte first-launch/reconfigure page, reachable afterwards via a "Connection" entry in the sidebar footer. Every fetch() in api.ts routes through fetchWithAuth so the same build works same-origin (browser prod, Vite dev proxy) or cross-origin (future Wails webview, remote access). Six gaps found against the plan and the live Caddy topology while implementing — documented in the plan's "Plan review" section, most notably: api's own /agent mount was never behind combinedAuth (fixed), and production's Authentik forward-auth needs a bearer-token bypass for API routes that this repo's Caddyfile.oikos reference copy now has, but the real dtoro/caddy-conf deploy does not yet. Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE query-token auth, and localStorage persistence all confirmed working in-browser. Full Go test suite and npm run build pass with no regressions against the pre-change baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59 lines
1.4 KiB
TypeScript
59 lines
1.4 KiB
TypeScript
import { writable } from 'svelte/store'
|
|
import { sseUrl } from '$lib/config'
|
|
|
|
export interface OikosEvent {
|
|
id: number
|
|
ts: string
|
|
type: string
|
|
entity_id?: string | null
|
|
severity: 'info' | 'warning' | 'critical'
|
|
source: string
|
|
data?: unknown
|
|
correlation_id?: string | null
|
|
}
|
|
|
|
const MAX_BUFFERED = 200
|
|
|
|
export const liveEvents = writable<OikosEvent[]>([])
|
|
export const connectionState = writable<'connecting' | 'open' | 'closed'>('connecting')
|
|
|
|
let source: EventSource | null = null
|
|
let subscriberCount = 0
|
|
|
|
function connect() {
|
|
if (source) return
|
|
connectionState.set('connecting')
|
|
// The browser's EventSource sends Last-Event-ID automatically on reconnect.
|
|
source = new EventSource(sseUrl('/api/v1/events/stream'))
|
|
|
|
source.onopen = () => connectionState.set('open')
|
|
|
|
source.onmessage = (ev) => {
|
|
try {
|
|
const parsed: OikosEvent = JSON.parse(ev.data)
|
|
liveEvents.update((events) => [parsed, ...events].slice(0, MAX_BUFFERED))
|
|
} catch {
|
|
// skip malformed
|
|
}
|
|
}
|
|
|
|
source.onerror = () => {
|
|
connectionState.set('closed')
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
source?.close()
|
|
source = null
|
|
}
|
|
|
|
// Reference-counted: the stream stays open as long as at least one page subscribes.
|
|
export function subscribeEvents(): () => void {
|
|
subscriberCount++
|
|
if (subscriberCount === 1) connect()
|
|
return () => {
|
|
subscriberCount--
|
|
if (subscriberCount === 0) disconnect()
|
|
}
|
|
}
|