feat(web): split SPA from oikos binary, require auth on every route
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>
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import { fetchWithAuth } from './config'
|
||||
|
||||
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
|
||||
// origin (relative vs. configured apiUrl) fresh on every call via
|
||||
// config.ts's apiBase(), so these can't be pre-resolved once at module load
|
||||
// (the config may not be known yet at import time, e.g. before Config.svelte
|
||||
// or a Wails-injected __OIKOS_CONFIG__ runs).
|
||||
const BASE = '/agent'
|
||||
const API = '/api/v1'
|
||||
|
||||
@@ -26,21 +33,21 @@ export interface Message {
|
||||
}
|
||||
|
||||
export async function fetchSessions(): Promise<Session[]> {
|
||||
const res = await fetch(`${BASE}/sessions`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
@@ -57,7 +64,7 @@ export interface PlanStep {
|
||||
}
|
||||
|
||||
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.steps ?? []
|
||||
@@ -74,16 +81,15 @@ export interface SessionQuestion {
|
||||
}
|
||||
|
||||
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.questions ?? []
|
||||
}
|
||||
|
||||
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer })
|
||||
})
|
||||
return res.ok
|
||||
@@ -105,9 +111,8 @@ export function streamChat(
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`${BASE}/chat`, {
|
||||
fetchWithAuth(`${BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||
signal: controller.signal
|
||||
}).then(async (res) => {
|
||||
@@ -161,7 +166,7 @@ export interface DashboardSummary {
|
||||
}
|
||||
|
||||
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
|
||||
const res = await fetch(`${API}/dashboard/summary`)
|
||||
const res = await fetchWithAuth(`${API}/dashboard/summary`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -194,7 +199,7 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.q) params.set('q', filters.q)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/entities?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -210,7 +215,7 @@ export async function fetchEvents(filters: EventFilters = {}): Promise<import('.
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
params.set('limit', '100')
|
||||
const res = await fetch(`${API}/events?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -235,7 +240,7 @@ export async function fetchApprovals(status?: string): Promise<Approval[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/approvals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/approvals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -246,9 +251,8 @@ export async function decideApproval(
|
||||
decision: 'approve' | 'deny' | 'revoke',
|
||||
note?: string
|
||||
): Promise<Approval | null> {
|
||||
const res = await fetch(`${API}/approvals/${id}/decision`, {
|
||||
const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision, note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -277,20 +281,20 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/executions?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/executions?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function getExecution(id: string): Promise<Execution | null> {
|
||||
const res = await fetch(`${API}/executions/${id}`)
|
||||
const res = await fetchWithAuth(`${API}/executions/${id}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function cancelExecution(id: string): Promise<Execution | null> {
|
||||
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
|
||||
const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -309,7 +313,7 @@ export interface ActivityItem {
|
||||
}
|
||||
|
||||
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
|
||||
const res = await fetch(`${API}/activity/recent?limit=${limit}`)
|
||||
const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -325,7 +329,7 @@ export interface SessionDigest {
|
||||
}
|
||||
|
||||
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
|
||||
const res = await fetch(`${API}/activity/session/${sessionId}`)
|
||||
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -338,7 +342,7 @@ export interface CapabilityTimelineItem {
|
||||
}
|
||||
|
||||
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
|
||||
const res = await fetch(`${API}/learning/timeline`)
|
||||
const res = await fetchWithAuth(`${API}/learning/timeline`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -351,7 +355,7 @@ export interface TrendBucket {
|
||||
}
|
||||
|
||||
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
|
||||
const res = await fetch(`${API}/learning/trend`)
|
||||
const res = await fetchWithAuth(`${API}/learning/trend`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -372,7 +376,7 @@ export interface Pattern {
|
||||
}
|
||||
|
||||
export async function fetchPatterns(): Promise<Pattern[]> {
|
||||
const res = await fetch(`${API}/patterns`)
|
||||
const res = await fetchWithAuth(`${API}/patterns`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -390,7 +394,7 @@ export interface Skill {
|
||||
}
|
||||
|
||||
export async function fetchSkills(): Promise<Skill[]> {
|
||||
const res = await fetch(`${API}/skills`)
|
||||
const res = await fetchWithAuth(`${API}/skills`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -418,22 +422,21 @@ export async function fetchSignals(filters: { state?: string; severity?: string
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
params.set('limit', '200')
|
||||
const res = await fetch(`${API}/signals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/signals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function ackSignal(id: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/resolve`, {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -441,9 +444,8 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
|
||||
}
|
||||
|
||||
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
|
||||
const res = await fetch(`${API}/signals/${id}/mute`, {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mute_until: muteUntil, note })
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -481,7 +483,7 @@ export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView
|
||||
if (filters.depth) params.set('depth', String(filters.depth))
|
||||
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
|
||||
if (filters.includeStatus) params.append('include', 'status')
|
||||
const res = await fetch(`${API}/graph?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/graph?${params}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -492,14 +494,14 @@ export interface BlastRadiusItem {
|
||||
}
|
||||
|
||||
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
|
||||
const res = await fetch(`${API}/entities/${id}/blast-radius`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function fetchEntity(id: string): Promise<Entity | null> {
|
||||
const res = await fetch(`${API}/entities/${id}`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${id}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -521,7 +523,7 @@ export interface MetricSeries {
|
||||
|
||||
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
|
||||
const res = await fetch(`${API}/metrics?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/metrics?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -559,13 +561,13 @@ export interface RecentKnowledge {
|
||||
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
|
||||
const params = new URLSearchParams()
|
||||
if (source) params.set('source', source)
|
||||
const res = await fetch(`${API}/knowledge/recent?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/recent?${params}`)
|
||||
if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
|
||||
const res = await fetch(`${API}/knowledge/${entityId}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/${entityId}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -573,7 +575,7 @@ export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeH
|
||||
|
||||
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/events?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -581,7 +583,7 @@ export async function fetchEntityEvents(entityId: string): Promise<import('./sto
|
||||
|
||||
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/signals?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/signals?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -589,7 +591,7 @@ export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
|
||||
|
||||
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
|
||||
const params = new URLSearchParams({ target: entityId, limit: '50' })
|
||||
const res = await fetch(`${API}/executions?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/executions?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -611,16 +613,16 @@ export interface Check {
|
||||
|
||||
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
|
||||
const params = new URLSearchParams({ target: targetSlug, limit: '50' })
|
||||
const res = await fetch(`${API}/checks?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/checks?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
|
||||
const res = await fetch(`${API}/checks/${id}`, {
|
||||
const res = await fetchWithAuth(`${API}/checks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` },
|
||||
headers: { 'If-Match': `"${version}"` },
|
||||
body: JSON.stringify(patch)
|
||||
})
|
||||
if (!res.ok) return null
|
||||
@@ -654,7 +656,7 @@ export async function fetchAgentActivity(filters: {
|
||||
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||
if (filters.entity_id) params.set('entity_id', filters.entity_id)
|
||||
params.set('limit', String(filters.limit ?? 200))
|
||||
const res = await fetch(`${API}/agent-activity?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/agent-activity?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -662,7 +664,7 @@ export async function fetchAgentActivity(filters: {
|
||||
|
||||
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) })
|
||||
const res = await fetch(`${API}/knowledge/search?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/knowledge/search?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -698,7 +700,7 @@ export async function fetchAudit(filters: {
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
|
||||
params.set('limit', String(filters.limit ?? 200))
|
||||
const res = await fetch(`${API}/audit?${params}`)
|
||||
const res = await fetchWithAuth(`${API}/audit?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
|
||||
Reference in New Issue
Block a user