// Runtime configuration for the SPA — server URL + auth token. Every fetch // call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works // identically whether it's served same-origin (browser prod, Vite dev proxy) // or cross-origin (Wails webview, remote access). See // plans/2026-07-12-wails-desktop-app.md 0.2. import { isOIDCConfigured, ensureToken, logout as oidcLogout } from './oidc' export interface OikosConfig { apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin token?: string // bearer token for auth isDesktop?: boolean // true when running inside the Wails desktop app } declare global { interface Window { __OIKOS_CONFIG__?: OikosConfig } } let cfg: OikosConfig | undefined export function initConfig(override?: OikosConfig) { cfg = override ?? window.__OIKOS_CONFIG__ if (cfg?.token) { localStorage.setItem('oikos_token', cfg.token) if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl) } } export function getConfig(): OikosConfig { if (!cfg) { const token = localStorage.getItem('oikos_token') const apiUrl = localStorage.getItem('oikos_api_url') if (token || apiUrl) { cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined } } } return cfg ?? { apiUrl: '' } } export function setConfig(next: OikosConfig) { cfg = next if (next.token) localStorage.setItem('oikos_token', next.token) else localStorage.removeItem('oikos_token') if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl) else localStorage.removeItem('oikos_api_url') } export function clearConfig() { cfg = { apiUrl: '' } localStorage.removeItem('oikos_token') localStorage.removeItem('oikos_api_url') } export function isConfigured(): boolean { return !!getConfig().token || isOIDCConfigured() } // Relative paths are used in dev (Vite proxy) and when the SPA shares an // origin with the API server (Caddy reverse proxy). Absolute paths are used // when the API server is on a different origin (Wails webview, remote access). export function apiBase(path: string): string { const c = getConfig() if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy return `${c.apiUrl}${path}` } // Resolves the auth token for a request: OIDC takes precedence, then static. // OIDC is async (may need to refresh an expired access_token); the static // fallback is synchronous. Returns the header value or null. async function resolveAuthHeader(): Promise { // Try OIDC first. ensureToken() refreshes if the cached token is expired or // missing; if it returns a token we use it. if (isOIDCConfigured()) { const tok = await ensureToken() if (tok) return `Bearer ${tok}` // OIDC session exists but couldn't yield a usable token (e.g. expired // access_token with no refresh_token). Fall through to the static token // if one was configured — better than a blanket 401. } const c = getConfig() if (c.token) return `Bearer ${c.token}` return null } // ---- Auth fetch wrapper ---- // Prepends the API base URL (absolute when configured, relative when unset // for the Vite dev proxy / same-origin prod) and adds the Authorization // header. Used by every fetch call in api.ts. // // OIDC tokens are short-lived; this wrapper awaits ensureToken() so an // expired access_token is refreshed before the request goes out, rather than // 401ing on the wire. On a 401 we flush the OIDC session once so the next // request can fall back to the static token (or re-prompt the operator). export async function fetchWithAuth(path: string, opts?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json', ...((opts?.headers as Record) ?? {}) } const authH = await resolveAuthHeader() if (authH) { headers['Authorization'] = authH } const res = await fetch(apiBase(path), { ...opts, headers }) // A 401 on a request we sent an Authorization header for means the token // the server just rejected is no longer valid. If OIDC is in use, clear it // so resolveAuthHeader() falls back to the static token next time (or the // operator gets re-prompted to log in). Don't loop: only one flush, and // only when we actually sent an Authorization header. if (res.status === 401 && authH && isOIDCConfigured()) { oidcLogout() } return res } // SSE path builder — EventSource doesn't take headers, so pass the token as // a query parameter (the SSE handler's combinedAuth checks it alongside the // Authorization header, only for this route). Async so the OIDC access token // can be refreshed before the EventSource is constructed. export async function sseUrl(path: string): Promise { const url = apiBase(path) const c = getConfig() // Prefer a fresh OIDC token (refreshes if expired); fall back to the static token. let token: string | null = null if (isOIDCConfigured()) { token = await ensureToken() } if (!token) token = c.token ?? null if (!token) return url const sep = url.includes('?') ? '&' : '?' return `${url}${sep}token=${encodeURIComponent(token)}` }