From 0c0f35a3a97205cfabc2116dd3f098c6366aa7f3 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 12 Jul 2026 15:49:42 +0200 Subject: [PATCH] feat(web): split SPA from oikos binary, require auth on every route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 9 +- CONTRIBUTING.md | 14 ++- Makefile | 11 ++- README.md | 31 ++++-- cmd/nomos/agent.go | 2 + cmd/nomos/assent.go | 3 + cmd/nomos/main.go | 22 +++-- cmd/oikos/main.go | 48 +-------- compose/caddy/Caddyfile.oikos | 45 ++++++--- compose/oikos/Dockerfile | 18 +--- docker-compose.yml | 11 +++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 9 ++ internal/httpapi/api_test.go | 33 +++++-- internal/httpapi/mutations_test.go | 4 +- internal/httpapi/phase3_test.go | 1 + internal/httpapi/server.go | 128 ++++++++++++------------ internal/httpapi/sse_test.go | 6 +- plans/2026-07-08-control-room-webui.md | 10 ++ plans/2026-07-12-wails-desktop-app.md | 130 ++++++++++++++++++++++++- plans/index.md | 2 +- web/dist/.gitkeep | 0 web/embed.go | 21 ---- web/package.json | 2 +- web/src/App.svelte | 35 ++++++- web/src/lib/api.ts | 92 ++++++++--------- web/src/lib/config.ts | 92 +++++++++++++++++ web/src/lib/stores/events.ts | 3 +- web/src/main.ts | 3 + web/src/pages/Config.svelte | 96 ++++++++++++++++++ web/vite.config.ts | 25 +++-- 32 files changed, 661 insertions(+), 248 deletions(-) delete mode 100644 web/dist/.gitkeep delete mode 100644 web/embed.go create mode 100644 web/src/lib/config.ts create mode 100644 web/src/pages/Config.svelte diff --git a/.gitignore b/.gitignore index bc6932d..e20dcf2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,8 @@ backups/ .env .infisical-credentials -# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the -# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a -# fresh checkout before the UI is built. -web/dist/* -!web/dist/.gitkeep +# Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build, +# deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md +# 0.1), so the output dir is just a build artifact. +web/dist/ web/node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a10eaa..e573c53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,10 +9,13 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md). - **Go 1.26+** (see `go.mod` for pinned version) - **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16` - **Docker** for the full dev stack +- **Node 22+** for `web/` (the control-room SPA — standalone, not part of the + compose stack or the `oikos` binary) ```bash -# Start dependencies (Postgres + Redis) -docker compose --profile dev up -d +# Start dependencies (Postgres + Redis). api/nomos require a shared bearer +# token — no dev-open bypass — so set one even for local dev. +OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d # Run all tests make test @@ -22,6 +25,9 @@ make test-db # Build the binary make build + +# SPA dev server (proxies to api/nomos, injecting the same token) +cd web && OIKOS_API_TOKEN=dev-token npm run dev ``` ## Project structure @@ -42,6 +48,8 @@ internal/ All Go packages domain/ Core types: entities, approvals, signals, patterns ontology/ Type hierarchy, relationship validation knowledge/ Knowledge YAML seed ingestion +web/ Control-room SPA (Svelte 5) — standalone, not embedded + in the oikos binary; see plans/2026-07-12-wails-desktop-app.md api/openapi.yaml API contract — the source of truth for endpoints migrations/ Forward-only SQL migrations (TimescaleDB) seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge @@ -68,6 +76,8 @@ docs/adr/ Architecture decision records | `make export` | Export DB state to YAML seeds | | `make dev` | Start compose dev stack | | `make clean` | Remove binary + test cache | +| `make ui` | Build the SPA (`web/dist/`) | +| `make deploy-ui` | Build + deploy the SPA to the Caddy host | ## Conventions diff --git a/Makefile b/Makefile index b0d81ea..d926d16 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy +.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui deploy-ui BINARY := oikos GO ?= go @@ -45,6 +45,15 @@ export: dev: docker compose --profile dev up -d +# The SPA is a standalone static build, no longer embedded in the oikos +# binary (plans/2026-07-12-wails-desktop-app.md 0.1) — deployed separately. +ui: + cd web && npm run build + +deploy-ui: ui + scp -r web/dist/* mac-mini:/var/www/oikos-ui/ + ssh mac-mini sudo systemctl reload caddy + clean: rm -f $(BINARY) $(GO) clean -testcache diff --git a/README.md b/README.md index 58db66f..b4945b1 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,24 @@ learns from outcomes, and escalates when uncertain. ## Quick start ```bash -# Dev stack (postgres + api + scheduler + notifier) -docker compose --profile dev up -d +# Dev stack (postgres + api + scheduler + notifier). The api/nomos +# services need a shared token — every route requires a real bearer +# credential, there's no dev-open bypass. +OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d # Full stack (adds Nomos agent gateway) -docker compose --profile full up -d +OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d # Build standalone binary go build -o bin/oikos -tags timetzdata ./cmd/oikos # Run all roles in one process (dev mode) OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \ + OIKOS_API_TOKEN=dev-token \ go run ./cmd/oikos all + +# Control-room SPA (separate from the Go binary — see web/) +cd web && OIKOS_API_TOKEN=dev-token npm run dev # http://localhost:5173 ``` ## Architecture @@ -66,9 +72,12 @@ Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](p ### API endpoints ```bash -curl http://localhost:8090/api/v1/entities?type=service # fleet -curl http://localhost:8090/api/v1/health # fleet health -curl http://localhost:8090/api/v1/agent-activity # agent log +curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \ + http://localhost:8090/api/v1/entities?type=service # fleet +curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \ + http://localhost:8090/api/v1/health # fleet health +curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \ + http://localhost:8090/api/v1/agent-activity # agent log ``` ### Nomos queries @@ -97,6 +106,15 @@ oikos secret list # enumerate SOPS secrets oikos secret migrate # SOPS → Infisical ``` +### Web UI + +`web/` is a standalone Svelte 5 SPA — not embedded in the `oikos` binary, not +part of `docker-compose.yml`. It talks to `api`/`nomos` over HTTP with a +bearer token entered on first launch (see `web/src/pages/Config.svelte`). +Build with `make ui`, deploy with `make deploy-ui` (Caddy serves the static +output). A native desktop wrapper is planned — see +[plans/2026-07-12-wails-desktop-app.md](plans/2026-07-12-wails-desktop-app.md). + ## Repo layout ``` @@ -105,6 +123,7 @@ cmd/nomos/ Nomos MCP client gateway internal/ Go packages (httpapi, mcp, scheduler, actuator, learning, notifier, policy, secrets, db, config, ontology, domain, knowledge) +web/ Control-room SPA (Svelte 5) — standalone, not embedded api/openapi.yaml API contract (OpenAPI 3.1) migrations/ Forward-only SQL migrations (TimescaleDB) seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge) diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index 40bc451..3e54ad9 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -55,6 +55,7 @@ type agent struct { agentID uuid.UUID reqOpts []option.RequestOption apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals + apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass) httpClient *http.Client } @@ -114,6 +115,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug agentID: agentID, reqOpts: reqOpts, apiBase: apiBase, + apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"), httpClient: &http.Client{Timeout: 15 * time.Second}, }, nil } diff --git a/cmd/nomos/assent.go b/cmd/nomos/assent.go index 881a655..228e1eb 100644 --- a/cmd/nomos/assent.go +++ b/cmd/nomos/assent.go @@ -164,6 +164,9 @@ func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, s return false, "", err } req.Header.Set("Content-Type", "application/json") + if a.apiToken != "" { + req.Header.Set("Authorization", "Bearer "+a.apiToken) + } resp, err := a.httpClient.Do(req) if err != nil { return false, "", err diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 14f75c2..61f7081 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -29,6 +29,10 @@ func main() { if mcpURL == "" { mcpURL = "http://localhost:8090/mcp" } + // api's combinedAuth requires a bearer token on every request (no + // dev-open bypass — plans/2026-07-12-wails-desktop-app.md 0.4); this is + // the same shared secret api validates against (OIKOS_MCP_BEARER_TOKEN). + mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN") agentSlug := os.Getenv("NOMOS_AGENT_SLUG") if agentSlug == "" { @@ -48,12 +52,12 @@ func main() { // One MCP client PER SESSION, not one shared client for the whole // process — see mcpClientPool's doc comment. A dedicated client is // created lazily on each session's first tool call. - clientPool := newMCPClientPool(mcpURL) + clientPool := newMCPClientPool(mcpURL, mcpToken) // Prove connectivity at startup the same way the old single-client // constructor did, so a misconfigured/unreachable MCP endpoint still // fails fast on boot instead of only on the first real chat. Doesn't // reuse the pool (nothing to key it by yet) — just a throwaway probe. - if probe, err := newMCPClient(mcpURL); err != nil { + if probe, err := newMCPClient(mcpURL, mcpToken); err != nil { slog.Error("nomos: mcp connect", "url", mcpURL, "error", err) os.Exit(1) } else { @@ -482,6 +486,7 @@ func truncate(s string, n int) string { type mcpClient struct { baseURL string + token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass) sessionID string http *http.Client nextID int @@ -500,9 +505,10 @@ type mcpClient struct { toolsCache []toolDef } -func newMCPClient(baseURL string) (*mcpClient, error) { +func newMCPClient(baseURL, token string) (*mcpClient, error) { c := &mcpClient{ baseURL: baseURL, + token: token, http: &http.Client{Timeout: 30 * time.Second}, } @@ -599,6 +605,9 @@ func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCRespo if c.sessionID != "" { req.Header.Set("Mcp-Session-Id", c.sessionID) } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.http.Do(req) if err != nil { @@ -721,6 +730,7 @@ func (c *mcpClient) close() { // at a time within a turn), but no longer block anyone else's. type mcpClientPool struct { baseURL string + token string mu sync.Mutex clients map[string]*pooledMCPClient } @@ -730,8 +740,8 @@ type pooledMCPClient struct { lastUsed time.Time } -func newMCPClientPool(baseURL string) *mcpClientPool { - return &mcpClientPool{baseURL: baseURL, clients: make(map[string]*pooledMCPClient)} +func newMCPClientPool(baseURL, token string) *mcpClientPool { + return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)} } // get returns the client for sessionID, creating and initializing one (a @@ -758,7 +768,7 @@ func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) { // Initialize outside the lock — it's a network round-trip, and holding // the pool mutex for it would serialize unrelated sessions' first calls // behind each other, undermining the whole point of this pool. - c, err := newMCPClient(p.baseURL) + c, err := newMCPClient(p.baseURL, p.token) if err != nil { return nil, err } diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index 573ffbe..95fe8e6 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -1,17 +1,14 @@ package main import ( - "bytes" "context" "fmt" - "io" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" - "time" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" @@ -21,50 +18,9 @@ import ( "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/scheduler" "github.com/dtoro/oikos/internal/secrets" - "github.com/dtoro/oikos/web" "github.com/jackc/pgx/v5" ) -// uiHandler serves the control-room SPA from assets embedded at build time -// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*; -// the /ui prefix is stripped to index into the embedded dist/ tree. Files are -// written via http.ServeContent (not http.FileServer) to avoid its -// index.html -> "./" canonical redirect, which loops for /ui/. -func uiHandler() http.Handler { - dist, err := web.DistFS() - if err != nil { - slog.Warn("ui: embedded assets unavailable", "error", err) - return http.NotFoundHandler() - } - serve := func(w http.ResponseWriter, r *http.Request, name string) bool { - f, err := dist.Open(name) - if err != nil { - return false - } - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { - return false - } - http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data)) - return true - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/") - if name == "" { - name = "index.html" - } - if serve(w, r, name) { - return - } - // SPA fallback: serve index.html for unknown client-side routes. - if serve(w, r, "index.html") { - return - } - http.NotFound(w, r) - }) -} - var schedulerRunner = scheduler.RunnerForMain() var notifierRunner = notifier.RunnerForMain() @@ -129,7 +85,7 @@ func main() { go notifierRunner(ctx, pool, cfg) slog.Info("all: starting api with scheduler + notifier in background") - if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil { + if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil { slog.Error("api failed", "error", err) os.Exit(1) } @@ -308,7 +264,7 @@ func runAPI(ctx context.Context, cfg config.Config) error { return fmt.Errorf("migrations: %w", err) } - err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()) + err = httpapi.ListenAndServe(ctx, pool, cfg) if err == http.ErrServerClosed { return nil } diff --git a/compose/caddy/Caddyfile.oikos b/compose/caddy/Caddyfile.oikos index 28c59de..ea42850 100644 --- a/compose/caddy/Caddyfile.oikos +++ b/compose/caddy/Caddyfile.oikos @@ -1,8 +1,16 @@ -# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover -# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). -# Replaces the old MCP server on apps/105 with the Docker stack on mac-mini. +# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover, updated for the +# client/server split (plans/2026-07-12-wails-desktop-app.md, Phase 0). +# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). THIS COPY +# IS A REFERENCE, NOT DEPLOYED FROM HERE — keep it in sync manually. +# +# The SPA is no longer embedded in the oikos binary; it's served here as +# static files (`make deploy-ui`). Every API/MCP/agent route now requires a +# bearer token in all cases (api's dev-open bypass was removed) — non-browser +# clients (Wails, curl, a future mobile client) can't complete Authentik's +# browser-session login, so those routes bypass `import authentik` the same +# way the enrollment endpoint always has and rely on api's own combinedAuth +# instead. See the Wails plan's "Plan review" section, gap 1. -# Oikos REST API (operator) — enrollment endpoint bypasses Authentik oikos.hubris.network { tls { dns ionos {env.IONOS_AUTH_API_TOKEN} @@ -11,25 +19,36 @@ oikos.hubris.network { handle @enroll { reverse_proxy :8090 } - # Nomos agent, same-origin for the control-room UI (EventSource/fetch can't - # set cross-origin auth headers). Authentik gates it; handle_path strips - # the /agent prefix so /agent/chat -> nomos /chat. - handle_path /agent/* { - import authentik - reverse_proxy :8092 + # Bearer-token clients — api's combinedAuth (internal/httpapi/server.go) + # is the real gate for all three; Authentik would just reject non-browser + # callers before they ever get there. /agent/* now goes through api's own + # (auth'd) proxy mount rather than straight to nomos:8092, so it's + # covered by the same check as /api/v1/* and /mcp. + @api path /api/v1/* /mcp /agent/* + handle @api { + reverse_proxy :8090 } + # Everything else: the static SPA shell. No sensitive data lives here — + # real enforcement is the bearer-token check above — Authentik is just a + # first line of defense against anonymous crawlers finding the bundle. handle { import authentik - reverse_proxy :8090 + root * /var/www/oikos-ui + file_server + try_files {path} /index.html } } -# Oikos MCP endpoint (agents) — no auth required +# Oikos MCP endpoint (agents) — bearer token required (api's combinedAuth), +# no separate gate here. mcp.hubris.network { reverse_proxy :8090 } -# Nomos gateway (workstation access) — formerly hermes.hubris.network +# Nomos's own gateway (workstation access) — still has NO auth of its own +# (C1, plans/2026-07-11-nomos-agent-code-review.md, still open). Anyone who +# can reach this host can talk to nomos directly, bypassing api entirely. +# Not fixed by the client/server split — tracked separately. nomos.hubris.network { reverse_proxy :8092 } diff --git a/compose/oikos/Dockerfile b/compose/oikos/Dockerfile index a5acf25..b1ab5b5 100644 --- a/compose/oikos/Dockerfile +++ b/compose/oikos/Dockerfile @@ -1,14 +1,7 @@ -# Multi-stage Dockerfile for Oikos (ADR 0001: single binary) -# Stage 1: build web UI -FROM node:22-alpine AS ui-builder - -WORKDIR /web -COPY web/package.json web/package-lock.json ./ -RUN npm ci -COPY web/ ./ -RUN npm run build - -# Stage 2: build Go binary +# Dockerfile for Oikos API server. The SPA is no longer embedded (see +# plans/2026-07-12-wails-desktop-app.md 0.1) — it's built and deployed +# separately as static files (see `make ui` / `make deploy-ui`). +# Stage 1: build Go binary FROM golang:1.26-alpine AS builder RUN apk add --no-cache git ca-certificates @@ -18,8 +11,6 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets. -COPY --from=ui-builder /web/dist ./web/dist RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos @@ -31,6 +22,5 @@ RUN apk add --no-cache ca-certificates openssh-client-default COPY --from=builder /oikos /oikos COPY --from=builder /build/seeds /seeds COPY --from=builder /build/migrations /migrations -# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed. ENTRYPOINT ["/oikos"] diff --git a/docker-compose.yml b/docker-compose.yml index 2b1b27b..a28d63f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,10 @@ # Docker Compose for Oikos development # Usage: docker compose up -d postgres (just the DB) # make dev (full dev stack) +# +# The SPA isn't part of this stack — it's a standalone static build served +# separately (`make ui`, `npm run dev` in web/), not embedded in the oikos +# image. See plans/2026-07-12-wails-desktop-app.md 0.1/0.6. services: postgres: @@ -60,6 +64,10 @@ services: OIKOS_API_LISTEN: ":8090" OIKOS_ENV: dev OIKOS_DEBUG: "true" + # No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) — + # every request needs this token. nomos uses the same value to call + # back into api's /mcp and /api/v1/approvals/*/decision. + OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} NOMOS_PROXY_URL: http://nomos:8092 volumes: @@ -129,6 +137,9 @@ services: OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro} DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable + # Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth + # rejects every request without it now (no dev-open bypass). + OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} ports: - "8092:8092" stop_signal: SIGTERM diff --git a/go.mod b/go.mod index 27b6756..74871fe 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.3 require ( github.com/getkin/kin-openapi v0.140.0 github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/cors v1.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 18695f2..da6658a 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/config/config.go b/internal/config/config.go index 368d5fc..bc9d56e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,11 @@ type Config struct { OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCClientID string // OIDC client ID (aud claim expected in JWT) + // CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md + // 0.3). Needed for the Wails webview and local dev (Vite on a different + // port than the API); a no-op when the SPA and API share an origin. + CORSAllowedOrigin string + // Observability Debug bool // verbose logging, probe payloads, SQL @@ -73,6 +78,7 @@ func Default() Config { DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable", APIListen: ":8090", APIEnv: "dev", + CORSAllowedOrigin: "*", SeedsDir: "seeds", MigrationsDir: "migrations", SchedulerInterval: 30 * time.Second, @@ -108,6 +114,9 @@ func FromEnv() Config { if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" { c.MCPBearerToken = v } + if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" { + c.CORSAllowedOrigin = v + } if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" { c.SeedsDir = v } diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go index 836ed6e..692ba44 100644 --- a/internal/httpapi/api_test.go +++ b/internal/httpapi/api_test.go @@ -93,15 +93,34 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler { } } - return NewHandler(handlerCtx, pool, cfg, nil) + return NewHandler(handlerCtx, pool, cfg) +} + +// testAuthToken is the static bearer token devConfig() configures. There is +// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4), +// so every test handler needs a real credential; get/postJSON/do inject it +// by default. Pass an explicit "" value for "Authorization" in headers to +// test the no-credential path. +const testAuthToken = "test-dev-token" + +// applyHeaders sets req's default Authorization header, then layers headers +// on top. A "" value deletes the header instead of setting it, so tests can +// exercise the missing-credential case. +func applyHeaders(req *http.Request, headers map[string]string) { + req.Header.Set("Authorization", "Bearer "+testAuthToken) + for k, v := range headers { + if v == "" { + req.Header.Del(k) + } else { + req.Header.Set(k, v) + } + } } func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) { t.Helper() req := httptest.NewRequest("GET", path, nil) - for k, v := range headers { - req.Header.Set(k, v) - } + applyHeaders(req, headers) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var body map[string]any @@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt t.Helper() req := httptest.NewRequest("POST", path, strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") + applyHeaders(req, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var body map[string]any @@ -122,7 +142,8 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt func devConfig() config.Config { c := config.Default() - c.APIEnv = "dev" // no tokens → dev-open auth + c.APIEnv = "dev" + c.APIToken = testAuthToken return c } @@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) { } // API requires the token - rec, body := get(t, h, "/api/v1/entities", nil) + rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""}) if rec.Code != 401 { t.Errorf("no token = %d, want 401 (%v)", rec.Code, body) } diff --git a/internal/httpapi/mutations_test.go b/internal/httpapi/mutations_test.go index 99b488d..2e90e51 100644 --- a/internal/httpapi/mutations_test.go +++ b/internal/httpapi/mutations_test.go @@ -24,9 +24,7 @@ func do(t *testing.T, h http.Handler, method, path string, body any, headers map } req := httptest.NewRequest(method, path, rdr) req.Header.Set("Content-Type", "application/json") - for k, v := range headers { - req.Header.Set(k, v) - } + applyHeaders(req, headers) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var decoded map[string]any diff --git a/internal/httpapi/phase3_test.go b/internal/httpapi/phase3_test.go index b7207d4..26b1f4c 100644 --- a/internal/httpapi/phase3_test.go +++ b/internal/httpapi/phase3_test.go @@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) { req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+testAuthToken) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 05ffe27..db20873 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -29,6 +29,7 @@ import ( "github.com/dtoro/oikos/internal/safego" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) @@ -70,7 +71,7 @@ type secretsBackend interface { // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // before closing the pool — otherwise the held connection never releases // and pool.Close() deadlocks. -func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler { +func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler { s := &Server{ pool: pool, cfg: cfg, @@ -88,6 +89,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler r.Use(middleware.Recoverer) r.Use(middleware.RequestID) r.Use(requestLogger) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{cfg.CORSAllowedOrigin}, + AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"}, + MaxAge: 86400, + })) // Liveness — no auth, no audit (plan SG18). Not exposed via Caddy. r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { @@ -130,7 +137,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler gen.HandlerWithOptions(strict, gen.ChiServerOptions{ BaseURL: "/api/v1", BaseRouter: r, - Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)}, + Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)}, ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) }, @@ -141,24 +148,26 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler // registration wins). The strict-server path can't Flush() per event; // this one uses the real ResponseWriter for real-time delivery. It // inherits the router's base middleware and applies auth via With(). - r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE) + // allowQueryToken=true: EventSource can't set custom headers, so the + // SPA passes the token as ?token=... instead of Authorization. + r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE) // Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the // Knowledge page's "what the system has learned" view. Registered after // HandlerWithOptions so it wins over any generated catch-all. - r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) + r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) // Custom (non-OpenAPI) routes: the global activity feed (recency-ordered, // unlike ListExecutions which sorts by target for pagination) and the // per-session "what did this session do" digest. - r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity) - r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) + r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity) + r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest) // Learning view: capability timeline + success trend, both derived from // executions (real, growing data) rather than the patterns/skills tables, // which are correctly modeled but have no writers anywhere yet. - r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline) - r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend) + r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline) + r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend) // Mount MCP at /mcp (plan R3-10) nomosAgentID := uuid.Nil @@ -170,33 +179,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" { _ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID) } - r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID)) - - r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) { - if uiHandler != nil { - uiHandler.ServeHTTP(w, req) - } - }) - r.Get("/ui", func(w http.ResponseWriter, req *http.Request) { - http.Redirect(w, req, "/ui/", http.StatusMovedPermanently) - }) - r.Get("/", func(w http.ResponseWriter, req *http.Request) { - http.Redirect(w, req, "/ui/", http.StatusMovedPermanently) - }) + r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID)) if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { target, _ := url.Parse(nomosURL) proxy := httputil.NewSingleHostReverseProxy(target) - r.Mount("/agent", http.StripPrefix("/agent", proxy)) + // Was unauthenticated (pre-existing gap, predates the client/server + // split — this mount was never wrapped in combinedAuth, unlike every + // other custom route below). Harmless while dev-open was in effect; + // a real hole now that every route needs a real credential. + r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy))) } return r } -// combinedAuth tries OIDC JWT validation first (if configured), falls back to -// static bearer token validation, and opens the gate in dev mode when no -// credentials are configured. -func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { +// combinedAuth tries OIDC JWT validation first (if configured), then falls +// back to static bearer token validation. Every request needs a valid +// credential — there is no dev-open bypass (closed as part of the +// client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the +// SPA is a separate client, a dev-open API is reachable from any origin). +// When allowQueryToken is set, a missing Authorization header falls back to +// a `?token=` query param — only used for the SSE route, since EventSource +// can't set custom headers. +func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler { hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != "" @@ -217,31 +223,14 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { } } - var staticTokens [][]byte - if cfg.APIToken != "" { - staticTokens = append(staticTokens, []byte(cfg.APIToken)) - } - if cfg.MCPBearerToken != "" { - staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken)) - } - - devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC - return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if devOpen { - ctx := context.WithValue(r.Context(), actorKey, actor{ - Type: "system", - Label: "dev:anonymous", - ID: "dev", - TokenType: "none", - }) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - auth := r.Header.Get("Authorization") raw, ok := strings.CutPrefix(auth, "Bearer ") + if (!ok || raw == "") && allowQueryToken { + raw = r.URL.Query().Get("token") + ok = raw != "" + } if !ok || raw == "" { writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "missing bearer token") @@ -275,21 +264,10 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { // Fall back to static tokens if hasStatic { - for _, t := range staticTokens { - if subtle.ConstantTimeCompare([]byte(raw), t) == 1 { - label := "operator:api" - if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 { - label = "agent:mcp" - } - ctx := context.WithValue(r.Context(), actorKey, actor{ - Type: label[:strings.IndexByte(label, ':')], - Label: label, - ID: raw[:8] + "...", - TokenType: "static", - }) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } + if act, ok := staticTokenActor(cfg, raw); ok { + ctx := context.WithValue(r.Context(), actorKey, act) + next.ServeHTTP(w, r.WithContext(ctx)) + return } } @@ -299,6 +277,28 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { } } +// staticTokenActor validates raw against the configured static bearer +// tokens (API token, MCP token) in constant time and returns the resolved +// actor. Shared between combinedAuth's header-based check and serveSSE's +// query-param check (EventSource can't set custom headers, so the SSE +// stream takes the token as ?token=...). +func staticTokenActor(cfg config.Config, raw string) (actor, bool) { + if raw == "" { + return actor{}, false + } + idPrefix := raw + if len(idPrefix) > 8 { + idPrefix = idPrefix[:8] + } + if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 { + return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true + } + if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 { + return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true + } + return actor{}, false +} + // jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT // verification, identified by its key ID (kid). type jwtVerificationKey struct { @@ -526,10 +526,10 @@ func requestLogger(next http.Handler) http.Handler { // ListenAndServe runs the API server with graceful shutdown on ctx cancel // (SG4): stop accepting, drain in-flight for up to 30s, then exit. -func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error { +func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { srv := &http.Server{ Addr: cfg.APIListen, - Handler: NewHandler(ctx, pool, cfg, uiHandler), + Handler: NewHandler(ctx, pool, cfg), ReadHeaderTimeout: 10 * time.Second, } diff --git a/internal/httpapi/sse_test.go b/internal/httpapi/sse_test.go index b60122f..7bccab9 100644 --- a/internal/httpapi/sse_test.go +++ b/internal/httpapi/sse_test.go @@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) { // Connect to the stream. req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil) + req.Header.Set("Authorization", "Bearer "+testAuthToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("connect stream: %v", err) @@ -60,7 +61,10 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) { // POSTing to the SAME live server (same DB → NOTIFY the listener sees). time.Sleep(300 * time.Millisecond) payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"}) - cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload)) + createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+testAuthToken) + cResp, err := http.DefaultClient.Do(createReq) if err != nil { t.Fatalf("trigger create: %v", err) } diff --git a/plans/2026-07-08-control-room-webui.md b/plans/2026-07-08-control-room-webui.md index ecb638f..7e0dc6a 100644 --- a/plans/2026-07-08-control-room-webui.md +++ b/plans/2026-07-08-control-room-webui.md @@ -4,6 +4,16 @@ `signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/ `relationship.ended` API calls don't emit `observability.Event`, and trusted-proxy header auth for Authentik was never added to `combinedAuth`). +**Superseded (2026-07-12):** the embed architecture below (`go:embed +all:web/dist`, served at `/ui/`) was removed — +[2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md) Phase 0 +separates the SPA from the `oikos` binary into a standalone static build, +served at `/` (no `/ui/` prefix), talking to the API over bearer-token +auth (the dev-open bypass mentioned nowhere in this plan was also removed). +The trusted-proxy-header gap noted above is moot under the new model — every +route requires a real bearer token regardless of what's in front of it. M1-M3 +and the SPA/component work below are unaffected; only the packaging and auth +sections are stale. N0-N3 (Nomos amendment: chat home + sessions), M1 (dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte component system), M2 (Operations ledger with approve/deny + cancel, Signals diff --git a/plans/2026-07-12-wails-desktop-app.md b/plans/2026-07-12-wails-desktop-app.md index 01574ed..4c79f5a 100644 --- a/plans/2026-07-12-wails-desktop-app.md +++ b/plans/2026-07-12-wails-desktop-app.md @@ -1,6 +1,119 @@ # 2026-07-12 — Wails desktop application -**Status:** Planned — not started +**Status:** In Progress — Phase 0 (0.1-0.4, 0.6) done and verified live +(browser: cross-origin static SPA + API on different ports, CORS, bearer +auth, SSE query-token auth, localStorage persistence across reload — see +"Plan review" for the gaps found and fixed along the way). Phase 1 (Wails +shell) not started. + +## Plan review — gaps found before starting Phase 0 + +Reviewed against the current codebase and the live Caddy topology +(`compose/caddy/Caddyfile.oikos`) before writing any code. Six gaps, each +with the resolution taken: + +1. **Authentik forward-auth vs. bearer-token clients.** The deployed + `oikos.hubris.network` site gates every route (including `/agent/*` and, + after this plan, `/api/v1/*`) with `import authentik` — a browser-session + forward-auth check, not a header a non-browser client can supply. Closing + the dev-open gate (0.4) makes every API route require a bearer token, but + says nothing about how a bearer-token client (Wails, curl, a future mobile + client) gets past Authentik's login redirect in front of it. Same shape as + the existing `@enroll` bypass for `/api/v1/clients/enroll`. + **Resolution:** updated the reference copy + ([Caddyfile.oikos](compose/caddy/Caddyfile.oikos)) with an `@api path + /api/v1/* /mcp /agent/*` bypass around `import authentik`, same pattern as + `@enroll`, and moved static-SPA serving into the `handle {}` fallback + (0.6). This repo's copy is not what's deployed — the real file lives in + `dtoro/caddy-conf` and auto-deploys from there — so the equivalent change + still needs to land there before a Wails client (or anything else that + can't complete Authentik's browser login) can actually reach the API in + production. Flagged explicitly as risk #6 below so it isn't discovered the + hard way. +2. **Nomos's own gateway (C1) is a parallel, unauthenticated path to the same + backend.** [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)'s + C1 finding — nomos's port 8092 has zero auth of its own — is still open. + Phase 0.3's CORS/auth work only touches `internal/httpapi` (the `api` + process); `cmd/nomos` is untouched. The architecture diagram in this plan + shows Caddy's `handle_path /agent/*` proxying straight to `:8092`, + bypassing `api`'s `combinedAuth` entirely and relying solely on Authentik. + Closing the API's dev-open gate does nothing for this path — nomos's + direct mesh-published port (`docker-compose.yml:133`) and + `nomos.hubris.network` remain reachable with no credential check at all. + **Resolution:** not fixed by this plan — flagged as a pre-existing, + independent gap (already tracked as C1) that the Wails desktop app + inherits rather than introduces. Added as risk #6 below so it isn't + mistaken for something Phase 0 closes. +3. **`github.com/go-chi/cors` isn't a dependency yet**, and the plan's sample + CORS config (`AllowCredentials: true` with a default `"*"` origin) is + spec-invalid — browsers and webviews reject a wildcard + `Access-Control-Allow-Origin` when credentials are requested. This API + authenticates via `Authorization: Bearer`, not cookies, so credentialed + CORS mode isn't needed at all. **Resolution:** drop `AllowCredentials` + from the middleware config in 0.3 rather than ship a setting that silently + breaks the first time an origin other than `*` is configured. +4. **Closing dev-open (0.4) breaks local `docker compose --profile dev up` + out of the box** — none of the compose services currently set a token, and + today they rely entirely on `OIKOS_ENV=dev` + devOpen. Worse: `cmd/nomos` + itself is an unauthenticated client of `api`'s `/mcp` endpoint and + `/api/v1/approvals/{id}/decision` (chat-assent approvals) — + `grep -rn "Authorization" cmd/nomos/*.go` returned nothing before this + fix. Closing dev-open without touching nomos would have broken nomos's own + connection to the API, not just local dev ergonomics; this wasn't called + out anywhere in the original plan text. **Resolution:** added a `token` + field threaded through `mcpClient`/`mcpClientPool` and `agent.apiToken`, + both reading `OIKOS_MCP_BEARER_TOKEN` (the same shared secret `api` + already validates static tokens against) and sent as `Authorization: + Bearer ...` on every request nomos makes to `api`. `docker-compose.yml` + sets `OIKOS_MCP_BEARER_TOKEN` (default `dev-token`) on both the `api` and + `nomos` services so local dev keeps working. +5. **0.2's `const API = apiBase('/api/v1')` pattern bakes in a stale origin.** + Module-level constants evaluate once, at import time — before + `main.ts`'s `initConfig()` runs (ES module imports are hoisted ahead of a + file's own top-level statements) and before `Config.svelte` or a + Wails-injected `window.__OIKOS_CONFIG__` can set `apiUrl`. A first-launch + Wails webview would resolve `API` to a relative path and try to fetch + `wails://.../api/v1/...`, which doesn't exist. **Resolution:** `api.ts` + keeps `BASE`/`API` as bare path prefixes (`/agent`, `/api/v1`, never + resolved to a URL) and lets `fetchWithAuth` call `apiBase()` fresh on + every request — the same fix pattern as gap 4's SSE snippet: resolve at + call time, not at module-load time. +6. **`api`'s own `/agent` reverse-proxy mount (to nomos) was never behind + `combinedAuth` — found while auditing every route for the dev-open + removal.** [server.go](../internal/httpapi/server.go)'s + `r.Mount("/agent", ...)` was registered directly on the base router, + unlike every other custom route (`/mcp`, `/api/v1/knowledge/recent`, + etc.), which all use `r.With(combinedAuth(cfg, false))`. Harmless while + dev-open made the whole API open anyway; a real hole the moment 0.4 closes + it — any request to `api`'s `/agent/*` would reach nomos with no + credential check at all, independent of C1 (nomos's *own* gateway on + :8092, still open) and independent of gap 1 (Caddy/Authentik). **Resolution:** + wrapped the mount in `combinedAuth(cfg, false)`, matching every other + route. + +Also: 0.4's local-dev token delivery ended up simpler than described — +"Vite injects it into `window.__OIKOS_CONFIG__` at dev time" isn't needed at +all for the relative-path dev case. The Vite proxy (0.2) already injects +`Authorization: Bearer $OIKOS_API_TOKEN` server-side on every proxied +`/api`/`/agent` request, so relative-path fetches during `npm run dev` are +authenticated before they leave the dev server — no client-side config +needed. `window.__OIKOS_CONFIG__` injection is still exactly what Phase 1's +Wails shell needs (absolute URL, no dev proxy to lean on). + +Also: 0.3's SSE-auth snippet checks `GetActor(r.Context()) == nil` *inside* +`serveSSE` and validates the query token there — but `serveSSE` only runs +after `combinedAuth` has already accepted or rejected the request, and +`combinedAuth` requires a header today, so `EventSource` requests (no custom +headers) never reach `serveSSE` at all; they 401 in the middleware first. +**Actual implementation:** `combinedAuth` itself takes an `allowQueryToken +bool`; when set (only for the `/api/v1/events/stream` route) it falls back to +`?token=` when the `Authorization` header is absent, before running the same +OIDC/static validation as every other route. This reuses all existing auth +logic instead of duplicating a static-token-only path inside `serveSSE`, and +keeps the gate at the middleware layer rather than half-open inside the +handler. The static-token comparison itself was extracted into +`staticTokenActor(cfg, raw)`, shared between the header and query-param +paths. ## Goal @@ -676,3 +789,18 @@ connect to the homelab → full app works with zero dev tools. handles multiple subscribers (fan-out via the subscriber list in `sse.go`). Each client gets its own connection and replay. No change needed. + +6. **Deploy-time Caddy changes this plan does not make.** Two changes are + required outside this repo before Phase 0's auth tightening actually + protects anything in production, both in `dtoro/caddy-conf`: + - Add a bearer-token bypass around `import authentik` for `/api/v1/*` and + `/mcp` on `oikos.hubris.network`, mirroring the existing `@enroll` + bypass — otherwise closing the dev-open gate just adds a second, + redundant auth layer behind Authentik's browser-session check, and + non-browser clients (Wails, curl) can never get past the first one. + - Nomos's gateway (port 8092) has no auth of its own (C1, tracked in + [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)). + Phase 0 does not fix this — the mesh-published port and + `nomos.hubris.network` remain open regardless of anything done here. + Treat C1 as a co-requisite for a production Wails rollout, not + something this plan's auth work incidentally covers. diff --git a/plans/index.md b/plans/index.md index 2990e06..09791fa 100644 --- a/plans/index.md +++ b/plans/index.md @@ -14,7 +14,7 @@ went sideways, open an investigation. | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | -| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | Planned — not started | +| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | In Progress — Phase 0 done, Phase 1 not started | ## Done diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/embed.go b/web/embed.go deleted file mode 100644 index 7f10c6e..0000000 --- a/web/embed.go +++ /dev/null @@ -1,21 +0,0 @@ -// Package web embeds the compiled control-room SPA (web/dist) into the oikos -// binary, preserving the single-binary deployment (ADR-0001). The dist tree is -// produced by `npm run build` (or the Docker ui-builder stage); a committed -// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not -// been built. -package web - -import ( - "embed" - "io/fs" -) - -//go:embed all:dist -var dist embed.FS - -// DistFS returns the built SPA rooted at dist/. When the UI has not been built -// (only the .gitkeep placeholder is present), Open("index.html") will fail and -// the caller serves a 404 — the binary still starts. -func DistFS() (fs.FS, error) { - return fs.Sub(dist, "dist") -} diff --git a/web/package.json b/web/package.json index 95836fd..f804f1b 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && touch dist/.gitkeep", + "build": "vite build", "preview": "vite preview" }, "devDependencies": { diff --git a/web/src/App.svelte b/web/src/App.svelte index 89876cb..ff00f94 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,9 +8,11 @@ import EntityDetail from './pages/EntityDetail.svelte' import Knowledge from './pages/Knowledge.svelte' import Learning from './pages/Learning.svelte' + import Config from './pages/Config.svelte' import { newChat } from '$lib/stores/chat' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { connectionState } from '$lib/stores/events' + import { isConfigured } from '$lib/config' import { onMount } from 'svelte' import * as Sidebar from '$lib/components/ui/sidebar' import * as Sheet from '$lib/components/ui/sheet' @@ -27,10 +29,12 @@ import NetworkIcon from '@lucide/svelte/icons/share-2' import SearchIcon from '@lucide/svelte/icons/search' import TrendingUpIcon from '@lucide/svelte/icons/trending-up' + import SettingsIcon from '@lucide/svelte/icons/settings' let page = $state('overview') let routeParam = $state('') let drawerOpen = $state(false) + let configured = $state(isConfigured()) const approvalsPending = $derived($summary?.approvals_pending ?? 0) const openSignals = $derived(openSignalCount($summary)) @@ -44,12 +48,14 @@ } sync() window.addEventListener('hashchange', sync) - const unsubscribeCtx = subscribeContext() + return () => window.removeEventListener('hashchange', sync) + }) - return () => { - window.removeEventListener('hashchange', sync) - unsubscribeCtx() - } + // Context (dashboard summary + approvals poll) and the SSE stream both + // authenticate — don't subscribe until a token exists. + $effect(() => { + if (!configured) return + return subscribeContext() }) function navigate(p: string) { @@ -67,6 +73,13 @@ ] +{#if !configured} + (configured = true)} + onCancel={isConfigured() ? () => (configured = true) : undefined} + /> +{:else} + @@ -145,6 +158,16 @@ Chat drawer + @@ -218,3 +241,5 @@ + +{/if} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 77a8353..9d00ec6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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 { - 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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { 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 { 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 { - 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 { 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 { 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 { export async function fetchEntityExecutions(entityId: string): Promise { 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 { 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 { - 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 { 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 ?? [] diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts new file mode 100644 index 0000000..fe80e54 --- /dev/null +++ b/web/src/lib/config.ts @@ -0,0 +1,92 @@ +// 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. + +export interface OikosConfig { + apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin + token?: string // bearer token for auth +} + +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 +} + +// 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}` +} + +// ---- 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. +export async function fetchWithAuth(path: string, opts?: RequestInit): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts?.headers as Record ?? {}) + } + const c = getConfig() + if (c.token) { + headers['Authorization'] = `Bearer ${c.token}` + } + + return fetch(apiBase(path), { ...opts, headers }) +} + +// 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). +export function sseUrl(path: string): string { + const c = getConfig() + const url = apiBase(path) + if (!c.token) return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}token=${encodeURIComponent(c.token)}` +} diff --git a/web/src/lib/stores/events.ts b/web/src/lib/stores/events.ts index 30b9c76..eb80481 100644 --- a/web/src/lib/stores/events.ts +++ b/web/src/lib/stores/events.ts @@ -1,4 +1,5 @@ import { writable } from 'svelte/store' +import { sseUrl } from '$lib/config' export interface OikosEvent { id: number @@ -23,7 +24,7 @@ function connect() { if (source) return connectionState.set('connecting') // The browser's EventSource sends Last-Event-ID automatically on reconnect. - source = new EventSource('/api/v1/events/stream') + source = new EventSource(sseUrl('/api/v1/events/stream')) source.onopen = () => connectionState.set('open') diff --git a/web/src/main.ts b/web/src/main.ts index bfe39f4..ae769f0 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,6 +1,9 @@ import { mount } from 'svelte' import App from './App.svelte' import './app.css' +import { initConfig } from '$lib/config' + +initConfig() const app = mount(App, { target: document.getElementById('app')! }) export default app diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte new file mode 100644 index 0000000..14e9a59 --- /dev/null +++ b/web/src/pages/Config.svelte @@ -0,0 +1,96 @@ + + +
+ + + Connect to Oikos + Enter the server URL and your access token. + + + + + Token + Login with Authentik (coming soon) + + +
{ e.preventDefault(); connect() }}> +
+ + +
+
+ + +
+ {#if error} +

{error}

+ {/if} +
+ + {#if onCancel} + + {/if} +
+ {#if existing.token} + + {/if} +
+
+
+
+
+
diff --git a/web/vite.config.ts b/web/vite.config.ts index 946471b..b0c3e24 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,9 +2,25 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' +// Injects OIKOS_API_TOKEN into proxied /api requests in dev — the API no +// longer has a dev-open bypass (plans/2026-07-12-wails-desktop-app.md 0.4), +// so `OIKOS_API_TOKEN=dev-token npm run dev` needs this to reach it. +function authProxy(target: string, rewrite?: (path: string) => string) { + return { + target, + ...(rewrite ? { rewrite } : {}), + configure: (proxy: any) => { + proxy.on('proxyReq', (proxyReq: any) => { + const token = process.env.OIKOS_API_TOKEN + if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) + }) + } + } +} + export default defineConfig({ plugins: [tailwindcss(), svelte()], - base: '/ui/', + base: '/', resolve: { alias: { $lib: '/src/lib' } }, @@ -14,14 +30,11 @@ export default defineConfig({ }, server: { proxy: { - '/api': 'http://localhost:8090', + '/api': authProxy('http://localhost:8090'), // Production Caddy strips /agent before forwarding to nomos // (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that // here so dev and prod agree on nomos's actual route paths. - '/agent': { - target: 'http://localhost:8092', - rewrite: (path) => path.replace(/^\/agent/, '') - } + '/agent': authProxy('http://localhost:8092', (path) => path.replace(/^\/agent/, '')) } } })