feat(web): split SPA from oikos binary, require auth on every route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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:
2026-07-12 15:49:42 +02:00
parent 346eb2f144
commit 0c0f35a3a9
32 changed files with 661 additions and 248 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}