.prettierrc.json was missing "semi": false, so prettier wanted to add semicolons to a codebase written without them (763 semicolon-free statements vs. 150 with, in hand-written .ts; zero hand-written .svelte files use them at all). That's why prettier --check failed on 249 files — not because the code was unformatted, but because the config didn't match the actual house style. Added "semi": false; left printWidth/etc as configured (printWidth barely moves the failure count: 218/213/212 files at 100/120/140). Ran `prettier --write .` with the corrected config. Verified semantics-preserving before and after: - eslint: 142 problems both before and after, byte-identical - build passes, 38/38 tests pass - token-stream diff (whitespace/semicolons/quotes normalized) on all 218 changed files: only 52 had any remaining token change, all either trailing-comma removal (matching trailingComma: "none") or import/ ternary reflow — no semantic changes - live smoke test: Knowledge, Tasks, Fleet map, and a chat window (AgentTrace, markdown, Scope graph, activity rail) all render correctly, no console errors Most of the diff is shadcn/ui vendor files (lib/components/ui/) moving from the CLI's own style (double quotes, tabs, semicolons) to house style; re-running `shadcn-svelte add` on a component will need a follow-up format pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
// 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<string | null> {
|
|
// 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<Response> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...((opts?.headers as Record<string, string>) ?? {})
|
|
}
|
|
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<string> {
|
|
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)}`
|
|
}
|