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

9
.gitignore vendored
View File

@@ -20,9 +20,8 @@ backups/
.env .env
.infisical-credentials .infisical-credentials
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the # Web UI (Svelte 5) — build artifacts. The SPA is a standalone static build,
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a # deployed separately from the oikos binary (plans/2026-07-12-wails-desktop-app.md
# fresh checkout before the UI is built. # 0.1), so the output dir is just a build artifact.
web/dist/* web/dist/
!web/dist/.gitkeep
web/node_modules/ web/node_modules/

View File

@@ -9,10 +9,13 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md).
- **Go 1.26+** (see `go.mod` for pinned version) - **Go 1.26+** (see `go.mod` for pinned version)
- **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16` - **PostgreSQL with TimescaleDB** — the compose stack includes `timescale/timescaledb:2.17.2-pg16`
- **Docker** for the full dev stack - **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 ```bash
# Start dependencies (Postgres + Redis) # Start dependencies (Postgres + Redis). api/nomos require a shared bearer
docker compose --profile dev up -d # 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 # Run all tests
make test make test
@@ -22,6 +25,9 @@ make test-db
# Build the binary # Build the binary
make build 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 ## Project structure
@@ -42,6 +48,8 @@ internal/ All Go packages
domain/ Core types: entities, approvals, signals, patterns domain/ Core types: entities, approvals, signals, patterns
ontology/ Type hierarchy, relationship validation ontology/ Type hierarchy, relationship validation
knowledge/ Knowledge YAML seed ingestion 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 api/openapi.yaml API contract — the source of truth for endpoints
migrations/ Forward-only SQL migrations (TimescaleDB) migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML: ontology, inventory, policy, knowledge 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 export` | Export DB state to YAML seeds |
| `make dev` | Start compose dev stack | | `make dev` | Start compose dev stack |
| `make clean` | Remove binary + test cache | | `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 ## Conventions

View File

@@ -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 BINARY := oikos
GO ?= go GO ?= go
@@ -45,6 +45,15 @@ export:
dev: dev:
docker compose --profile dev up -d 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: clean:
rm -f $(BINARY) rm -f $(BINARY)
$(GO) clean -testcache $(GO) clean -testcache

View File

@@ -13,18 +13,24 @@ learns from outcomes, and escalates when uncertain.
## Quick start ## Quick start
```bash ```bash
# Dev stack (postgres + api + scheduler + notifier) # Dev stack (postgres + api + scheduler + notifier). The api/nomos
docker compose --profile dev up -d # 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) # 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 # Build standalone binary
go build -o bin/oikos -tags timetzdata ./cmd/oikos go build -o bin/oikos -tags timetzdata ./cmd/oikos
# Run all roles in one process (dev mode) # Run all roles in one process (dev mode)
OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" \
OIKOS_API_TOKEN=dev-token \
go run ./cmd/oikos all 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 ## Architecture
@@ -66,9 +72,12 @@ Full plan: [plans/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md](p
### API endpoints ### API endpoints
```bash ```bash
curl http://localhost:8090/api/v1/entities?type=service # fleet curl -H "Authorization: Bearer $OIKOS_API_TOKEN" \
curl http://localhost:8090/api/v1/health # fleet health http://localhost:8090/api/v1/entities?type=service # fleet
curl http://localhost:8090/api/v1/agent-activity # agent log 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 ### Nomos queries
@@ -97,6 +106,15 @@ oikos secret list # enumerate SOPS secrets
oikos secret migrate # SOPS → Infisical 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 ## Repo layout
``` ```
@@ -105,6 +123,7 @@ cmd/nomos/ Nomos MCP client gateway
internal/ Go packages (httpapi, mcp, scheduler, actuator, learning, internal/ Go packages (httpapi, mcp, scheduler, actuator, learning,
notifier, policy, secrets, db, config, ontology, domain, notifier, policy, secrets, db, config, ontology, domain,
knowledge) knowledge)
web/ Control-room SPA (Svelte 5) — standalone, not embedded
api/openapi.yaml API contract (OpenAPI 3.1) api/openapi.yaml API contract (OpenAPI 3.1)
migrations/ Forward-only SQL migrations (TimescaleDB) migrations/ Forward-only SQL migrations (TimescaleDB)
seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge) seeds/ Bootstrap YAML (ontology, inventory, policy, knowledge)

View File

@@ -55,6 +55,7 @@ type agent struct {
agentID uuid.UUID agentID uuid.UUID
reqOpts []option.RequestOption reqOpts []option.RequestOption
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals 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 httpClient *http.Client
} }
@@ -114,6 +115,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
agentID: agentID, agentID: agentID,
reqOpts: reqOpts, reqOpts: reqOpts,
apiBase: apiBase, apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
}, nil }, nil
} }

View File

@@ -164,6 +164,9 @@ func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, s
return false, "", err return false, "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if a.apiToken != "" {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
}
resp, err := a.httpClient.Do(req) resp, err := a.httpClient.Do(req)
if err != nil { if err != nil {
return false, "", err return false, "", err

View File

@@ -29,6 +29,10 @@ func main() {
if mcpURL == "" { if mcpURL == "" {
mcpURL = "http://localhost:8090/mcp" 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") agentSlug := os.Getenv("NOMOS_AGENT_SLUG")
if agentSlug == "" { if agentSlug == "" {
@@ -48,12 +52,12 @@ func main() {
// One MCP client PER SESSION, not one shared client for the whole // One MCP client PER SESSION, not one shared client for the whole
// process — see mcpClientPool's doc comment. A dedicated client is // process — see mcpClientPool's doc comment. A dedicated client is
// created lazily on each session's first tool call. // 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 // Prove connectivity at startup the same way the old single-client
// constructor did, so a misconfigured/unreachable MCP endpoint still // constructor did, so a misconfigured/unreachable MCP endpoint still
// fails fast on boot instead of only on the first real chat. Doesn't // 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. // 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) slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
os.Exit(1) os.Exit(1)
} else { } else {
@@ -482,6 +486,7 @@ func truncate(s string, n int) string {
type mcpClient struct { type mcpClient struct {
baseURL string baseURL string
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
sessionID string sessionID string
http *http.Client http *http.Client
nextID int nextID int
@@ -500,9 +505,10 @@ type mcpClient struct {
toolsCache []toolDef toolsCache []toolDef
} }
func newMCPClient(baseURL string) (*mcpClient, error) { func newMCPClient(baseURL, token string) (*mcpClient, error) {
c := &mcpClient{ c := &mcpClient{
baseURL: baseURL, baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 30 * time.Second}, 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 != "" { if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", 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) resp, err := c.http.Do(req)
if err != nil { if err != nil {
@@ -721,6 +730,7 @@ func (c *mcpClient) close() {
// at a time within a turn), but no longer block anyone else's. // at a time within a turn), but no longer block anyone else's.
type mcpClientPool struct { type mcpClientPool struct {
baseURL string baseURL string
token string
mu sync.Mutex mu sync.Mutex
clients map[string]*pooledMCPClient clients map[string]*pooledMCPClient
} }
@@ -730,8 +740,8 @@ type pooledMCPClient struct {
lastUsed time.Time lastUsed time.Time
} }
func newMCPClientPool(baseURL string) *mcpClientPool { func newMCPClientPool(baseURL, token string) *mcpClientPool {
return &mcpClientPool{baseURL: baseURL, clients: make(map[string]*pooledMCPClient)} return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
} }
// get returns the client for sessionID, creating and initializing one (a // 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 // Initialize outside the lock — it's a network round-trip, and holding
// the pool mutex for it would serialize unrelated sessions' first calls // the pool mutex for it would serialize unrelated sessions' first calls
// behind each other, undermining the whole point of this pool. // 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,17 +1,14 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
@@ -21,50 +18,9 @@ import (
"github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/scheduler" "github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets" "github.com/dtoro/oikos/internal/secrets"
"github.com/dtoro/oikos/web"
"github.com/jackc/pgx/v5" "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 schedulerRunner = scheduler.RunnerForMain()
var notifierRunner = notifier.RunnerForMain() var notifierRunner = notifier.RunnerForMain()
@@ -129,7 +85,7 @@ func main() {
go notifierRunner(ctx, pool, cfg) go notifierRunner(ctx, pool, cfg)
slog.Info("all: starting api with scheduler + notifier in background") 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) slog.Error("api failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -308,7 +264,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
return fmt.Errorf("migrations: %w", err) return fmt.Errorf("migrations: %w", err)
} }
err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()) err = httpapi.ListenAndServe(ctx, pool, cfg)
if err == http.ErrServerClosed { if err == http.ErrServerClosed {
return nil return nil
} }

View File

@@ -1,8 +1,16 @@
# Caddy reverse-proxy snippet for Oikos — Phase 6 cutover # Caddy reverse-proxy snippet for Oikos — Phase 6 cutover, updated for the
# Lives in dtoro/caddy-conf repo; auto-deploys to caddy (LXC 121). # client/server split (plans/2026-07-12-wails-desktop-app.md, Phase 0).
# Replaces the old MCP server on apps/105 with the Docker stack on mac-mini. # 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 { oikos.hubris.network {
tls { tls {
dns ionos {env.IONOS_AUTH_API_TOKEN} dns ionos {env.IONOS_AUTH_API_TOKEN}
@@ -11,25 +19,36 @@ oikos.hubris.network {
handle @enroll { handle @enroll {
reverse_proxy <mac-mini-mesh-ip>:8090 reverse_proxy <mac-mini-mesh-ip>:8090
} }
# Nomos agent, same-origin for the control-room UI (EventSource/fetch can't # Bearer-token clients — api's combinedAuth (internal/httpapi/server.go)
# set cross-origin auth headers). Authentik gates it; handle_path strips # is the real gate for all three; Authentik would just reject non-browser
# the /agent prefix so /agent/chat -> nomos /chat. # callers before they ever get there. /agent/* now goes through api's own
handle_path /agent/* { # (auth'd) proxy mount rather than straight to nomos:8092, so it's
import authentik # covered by the same check as /api/v1/* and /mcp.
reverse_proxy <mac-mini-mesh-ip>:8092 @api path /api/v1/* /mcp /agent/*
handle @api {
reverse_proxy <mac-mini-mesh-ip>: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 { handle {
import authentik import authentik
reverse_proxy <mac-mini-mesh-ip>: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 { mcp.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8090 reverse_proxy <mac-mini-mesh-ip>: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 { nomos.hubris.network {
reverse_proxy <mac-mini-mesh-ip>:8092 reverse_proxy <mac-mini-mesh-ip>:8092
} }

View File

@@ -1,14 +1,7 @@
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary) # Dockerfile for Oikos API server. The SPA is no longer embedded (see
# Stage 1: build web UI # plans/2026-07-12-wails-desktop-app.md 0.1) — it's built and deployed
FROM node:22-alpine AS ui-builder # separately as static files (see `make ui` / `make deploy-ui`).
# Stage 1: build Go binary
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Stage 2: build Go binary
FROM golang:1.26-alpine AS builder FROM golang:1.26-alpine AS builder
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
@@ -18,8 +11,6 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 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 /oikos /oikos
COPY --from=builder /build/seeds /seeds COPY --from=builder /build/seeds /seeds
COPY --from=builder /build/migrations /migrations COPY --from=builder /build/migrations /migrations
# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed.
ENTRYPOINT ["/oikos"] ENTRYPOINT ["/oikos"]

View File

@@ -1,6 +1,10 @@
# Docker Compose for Oikos development # Docker Compose for Oikos development
# Usage: docker compose up -d postgres (just the DB) # Usage: docker compose up -d postgres (just the DB)
# make dev (full dev stack) # 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: services:
postgres: postgres:
@@ -60,6 +64,10 @@ services:
OIKOS_API_LISTEN: ":8090" OIKOS_API_LISTEN: ":8090"
OIKOS_ENV: dev OIKOS_ENV: dev
OIKOS_DEBUG: "true" 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} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
NOMOS_PROXY_URL: http://nomos:8092 NOMOS_PROXY_URL: http://nomos:8092
volumes: volumes:
@@ -129,6 +137,9 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro} NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable 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: ports:
- "8092:8092" - "8092:8092"
stop_signal: SIGTERM stop_signal: SIGTERM

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.26.3
require ( require (
github.com/getkin/kin-openapi v0.140.0 github.com/getkin/kin-openapi v0.140.0
github.com/go-chi/chi/v5 v5.3.1 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/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3 github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0

2
go.sum
View File

@@ -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/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 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=

View File

@@ -24,6 +24,11 @@ type Config struct {
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) 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) 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 // Observability
Debug bool // verbose logging, probe payloads, SQL Debug bool // verbose logging, probe payloads, SQL
@@ -73,6 +78,7 @@ func Default() Config {
DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable", DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable",
APIListen: ":8090", APIListen: ":8090",
APIEnv: "dev", APIEnv: "dev",
CORSAllowedOrigin: "*",
SeedsDir: "seeds", SeedsDir: "seeds",
MigrationsDir: "migrations", MigrationsDir: "migrations",
SchedulerInterval: 30 * time.Second, SchedulerInterval: 30 * time.Second,
@@ -108,6 +114,9 @@ func FromEnv() Config {
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" { if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
c.MCPBearerToken = v c.MCPBearerToken = v
} }
if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" {
c.CORSAllowedOrigin = v
}
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" { if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
c.SeedsDir = v c.SeedsDir = v
} }

View File

@@ -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) { func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper() t.Helper()
req := httptest.NewRequest("GET", path, nil) req := httptest.NewRequest("GET", path, nil)
for k, v := range headers { applyHeaders(req, headers)
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var body map[string]any var body map[string]any
@@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
t.Helper() t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(payload)) req := httptest.NewRequest("POST", path, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
applyHeaders(req, nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var body map[string]any 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 { func devConfig() config.Config {
c := config.Default() c := config.Default()
c.APIEnv = "dev" // no tokens → dev-open auth c.APIEnv = "dev"
c.APIToken = testAuthToken
return c return c
} }
@@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) {
} }
// API requires the token // 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 { if rec.Code != 401 {
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body) t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
} }

View File

@@ -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 := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
for k, v := range headers { applyHeaders(req, headers)
req.Header.Set(k, v)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var decoded map[string]any var decoded map[string]any

View File

@@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) {
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body)) req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+testAuthToken)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)

View File

@@ -29,6 +29,7 @@ import (
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -70,7 +71,7 @@ type secretsBackend interface {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx // holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases // before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks. // 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{ s := &Server{
pool: pool, pool: pool,
cfg: cfg, 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.Recoverer)
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(requestLogger) 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. // Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { 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{ gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1", BaseURL: "/api/v1",
BaseRouter: r, BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)}, Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad 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; // registration wins). The strict-server path can't Flush() per event;
// this one uses the real ResponseWriter for real-time delivery. It // this one uses the real ResponseWriter for real-time delivery. It
// inherits the router's base middleware and applies auth via With(). // 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 // Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
// Knowledge page's "what the system has learned" view. Registered after // Knowledge page's "what the system has learned" view. Registered after
// HandlerWithOptions so it wins over any generated catch-all. // 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, // Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the // unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest. // per-session "what did this session do" digest.
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity) r.With(combinedAuth(cfg, false)).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/session/{id}", s.serveSessionDigest)
// Learning view: capability timeline + success trend, both derived from // Learning view: capability timeline + success trend, both derived from
// executions (real, growing data) rather than the patterns/skills tables, // executions (real, growing data) rather than the patterns/skills tables,
// which are correctly modeled but have no writers anywhere yet. // 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, false)).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/trend", s.serveLearningTrend)
// Mount MCP at /mcp (plan R3-10) // Mount MCP at /mcp (plan R3-10)
nomosAgentID := uuid.Nil 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 != "" { if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID) _ = 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.With(combinedAuth(cfg, false)).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)
})
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL) target, _ := url.Parse(nomosURL)
proxy := httputil.NewSingleHostReverseProxy(target) 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 return r
} }
// combinedAuth tries OIDC JWT validation first (if configured), falls back to // combinedAuth tries OIDC JWT validation first (if configured), then falls
// static bearer token validation, and opens the gate in dev mode when no // back to static bearer token validation. Every request needs a valid
// credentials are configured. // credential — there is no dev-open bypass (closed as part of the
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler { // 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 != "" hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != "" 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ") raw, ok := strings.CutPrefix(auth, "Bearer ")
if (!ok || raw == "") && allowQueryToken {
raw = r.URL.Query().Get("token")
ok = raw != ""
}
if !ok || raw == "" { if !ok || raw == "" {
writeProblem(w, r, http.StatusUnauthorized, "unauthorized", writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing bearer token") "missing bearer token")
@@ -275,23 +264,12 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// Fall back to static tokens // Fall back to static tokens
if hasStatic { if hasStatic {
for _, t := range staticTokens { if act, ok := staticTokenActor(cfg, raw); ok {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 { ctx := context.WithValue(r.Context(), actorKey, act)
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)) next.ServeHTTP(w, r.WithContext(ctx))
return return
} }
} }
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized", writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"invalid or expired bearer token") "invalid or expired bearer token")
@@ -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 // jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid). // verification, identified by its key ID (kid).
type jwtVerificationKey struct { 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 // ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit. // (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{ srv := &http.Server{
Addr: cfg.APIListen, Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, uiHandler), Handler: NewHandler(ctx, pool, cfg),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }

View File

@@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// Connect to the stream. // Connect to the stream.
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil) req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
req.Header.Set("Authorization", "Bearer "+testAuthToken)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
t.Fatalf("connect stream: %v", err) 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). // POSTing to the SAME live server (same DB → NOTIFY the listener sees).
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"}) 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 { if err != nil {
t.Fatalf("trigger create: %v", err) t.Fatalf("trigger create: %v", err)
} }

View File

@@ -4,6 +4,16 @@
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/ `signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
`relationship.ended` API calls don't emit `observability.Event`, and `relationship.ended` API calls don't emit `observability.Event`, and
trusted-proxy header auth for Authentik was never added to `combinedAuth`). 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 N0-N3 (Nomos amendment: chat home + sessions), M1
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte (dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
component system), M2 (Operations ledger with approve/deny + cancel, Signals component system), M2 (Operations ledger with approve/deny + cancel, Signals

View File

@@ -1,6 +1,119 @@
# 2026-07-12 — Wails desktop application # 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 ## 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 handles multiple subscribers (fan-out via the subscriber list in
`sse.go`). Each client gets its own connection and replay. No change `sse.go`). Each client gets its own connection and replay. No change
needed. 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.

View File

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

0
web/dist/.gitkeep vendored
View File

View File

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

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build && touch dist/.gitkeep", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -8,9 +8,11 @@
import EntityDetail from './pages/EntityDetail.svelte' import EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte' import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte' import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import { newChat } from '$lib/stores/chat' import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context' import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events' import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import * as Sidebar from '$lib/components/ui/sidebar' import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet' import * as Sheet from '$lib/components/ui/sheet'
@@ -27,10 +29,12 @@
import NetworkIcon from '@lucide/svelte/icons/share-2' import NetworkIcon from '@lucide/svelte/icons/share-2'
import SearchIcon from '@lucide/svelte/icons/search' import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up' import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
let page = $state('overview') let page = $state('overview')
let routeParam = $state('') let routeParam = $state('')
let drawerOpen = $state(false) let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0) const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary)) const openSignals = $derived(openSignalCount($summary))
@@ -44,12 +48,14 @@
} }
sync() sync()
window.addEventListener('hashchange', sync) window.addEventListener('hashchange', sync)
const unsubscribeCtx = subscribeContext() return () => window.removeEventListener('hashchange', sync)
})
return () => { // Context (dashboard summary + approvals poll) and the SSE stream both
window.removeEventListener('hashchange', sync) // authenticate — don't subscribe until a token exists.
unsubscribeCtx() $effect(() => {
} if (!configured) return
return subscribeContext()
}) })
function navigate(p: string) { function navigate(p: string) {
@@ -67,6 +73,13 @@
] ]
</script> </script>
{#if !configured}
<Config
onConnected={() => (configured = true)}
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster /> <Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);"> <Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
@@ -145,6 +158,16 @@
<PanelRightIcon /> <PanelRightIcon />
<span>Chat drawer</span> <span>Chat drawer</span>
</Button> </Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer> </Sidebar.Footer>
</Sidebar.Root> </Sidebar.Root>
@@ -218,3 +241,5 @@
</div> </div>
</Sheet.Content> </Sheet.Content>
</Sheet.Root> </Sheet.Root>
{/if}

View File

@@ -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 BASE = '/agent'
const API = '/api/v1' const API = '/api/v1'
@@ -26,21 +33,21 @@ export interface Message {
} }
export async function fetchSessions(): Promise<Session[]> { export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${BASE}/sessions`) const res = await fetchWithAuth(`${BASE}/sessions`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.sessions ?? [] return data.sessions ?? []
} }
export async function fetchMessages(sessionId: string): Promise<Message[]> { export async function fetchMessages(sessionId: string): Promise<Message[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}`) const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.messages ?? [] return data.messages ?? []
} }
export async function deleteSession(sessionId: string): Promise<boolean> { export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' }) const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok return res.ok
} }
@@ -57,7 +64,7 @@ export interface PlanStep {
} }
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> { export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`) const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.steps ?? [] return data.steps ?? []
@@ -74,16 +81,15 @@ export interface SessionQuestion {
} }
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> { export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`) const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.questions ?? [] return data.questions ?? []
} }
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> { export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer }) body: JSON.stringify({ answer })
}) })
return res.ok return res.ok
@@ -105,9 +111,8 @@ export function streamChat(
): AbortController { ): AbortController {
const controller = new AbortController() const controller = new AbortController()
fetch(`${BASE}/chat`, { fetchWithAuth(`${BASE}/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId ?? undefined }), body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
signal: controller.signal signal: controller.signal
}).then(async (res) => { }).then(async (res) => {
@@ -161,7 +166,7 @@ export interface DashboardSummary {
} }
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> { export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
const res = await fetch(`${API}/dashboard/summary`) const res = await fetchWithAuth(`${API}/dashboard/summary`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -194,7 +199,7 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
if (filters.state) params.set('state', filters.state) if (filters.state) params.set('state', filters.state)
if (filters.q) params.set('q', filters.q) if (filters.q) params.set('q', filters.q)
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`${API}/entities?${params}`) const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -210,7 +215,7 @@ export async function fetchEvents(filters: EventFilters = {}): Promise<import('.
if (filters.type) params.set('type', filters.type) if (filters.type) params.set('type', filters.type)
if (filters.severity) params.set('severity', filters.severity) if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '100') params.set('limit', '100')
const res = await fetch(`${API}/events?${params}`) const res = await fetchWithAuth(`${API}/events?${params}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -235,7 +240,7 @@ export async function fetchApprovals(status?: string): Promise<Approval[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (status) params.set('status', status) if (status) params.set('status', status)
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`${API}/approvals?${params}`) const res = await fetchWithAuth(`${API}/approvals?${params}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -246,9 +251,8 @@ export async function decideApproval(
decision: 'approve' | 'deny' | 'revoke', decision: 'approve' | 'deny' | 'revoke',
note?: string note?: string
): Promise<Approval | null> { ): Promise<Approval | null> {
const res = await fetch(`${API}/approvals/${id}/decision`, { const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note }) body: JSON.stringify({ decision, note })
}) })
if (!res.ok) return null if (!res.ok) return null
@@ -277,20 +281,20 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (status) params.set('status', status) if (status) params.set('status', status)
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`${API}/executions?${params}`) const res = await fetchWithAuth(`${API}/executions?${params}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
} }
export async function getExecution(id: string): Promise<Execution | null> { export async function getExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}`) const res = await fetchWithAuth(`${API}/executions/${id}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
export async function cancelExecution(id: string): Promise<Execution | null> { export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' }) const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -309,7 +313,7 @@ export interface ActivityItem {
} }
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> { export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
const res = await fetch(`${API}/activity/recent?limit=${limit}`) const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -325,7 +329,7 @@ export interface SessionDigest {
} }
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> { export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
const res = await fetch(`${API}/activity/session/${sessionId}`) const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -338,7 +342,7 @@ export interface CapabilityTimelineItem {
} }
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> { export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
const res = await fetch(`${API}/learning/timeline`) const res = await fetchWithAuth(`${API}/learning/timeline`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -351,7 +355,7 @@ export interface TrendBucket {
} }
export async function fetchLearningTrend(): Promise<TrendBucket[]> { export async function fetchLearningTrend(): Promise<TrendBucket[]> {
const res = await fetch(`${API}/learning/trend`) const res = await fetchWithAuth(`${API}/learning/trend`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -372,7 +376,7 @@ export interface Pattern {
} }
export async function fetchPatterns(): Promise<Pattern[]> { export async function fetchPatterns(): Promise<Pattern[]> {
const res = await fetch(`${API}/patterns`) const res = await fetchWithAuth(`${API}/patterns`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -390,7 +394,7 @@ export interface Skill {
} }
export async function fetchSkills(): Promise<Skill[]> { export async function fetchSkills(): Promise<Skill[]> {
const res = await fetch(`${API}/skills`) const res = await fetchWithAuth(`${API}/skills`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] 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.state) params.set('state', filters.state)
if (filters.severity) params.set('severity', filters.severity) if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '200') params.set('limit', '200')
const res = await fetch(`${API}/signals?${params}`) const res = await fetchWithAuth(`${API}/signals?${params}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
} }
export async function ackSignal(id: string): Promise<Signal | null> { export async function ackSignal(id: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' }) const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' })
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> { export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/resolve`, { const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note }) body: JSON.stringify({ note })
}) })
if (!res.ok) return null if (!res.ok) return null
@@ -441,9 +444,8 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
} }
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> { export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/mute`, { const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mute_until: muteUntil, note }) body: JSON.stringify({ mute_until: muteUntil, note })
}) })
if (!res.ok) return null if (!res.ok) return null
@@ -481,7 +483,7 @@ export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView
if (filters.depth) params.set('depth', String(filters.depth)) if (filters.depth) params.set('depth', String(filters.depth))
for (const rt of filters.relType ?? []) params.append('rel_type', rt) for (const rt of filters.relType ?? []) params.append('rel_type', rt)
if (filters.includeStatus) params.append('include', 'status') if (filters.includeStatus) params.append('include', 'status')
const res = await fetch(`${API}/graph?${params}`) const res = await fetchWithAuth(`${API}/graph?${params}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -492,14 +494,14 @@ export interface BlastRadiusItem {
} }
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> { export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetch(`${API}/entities/${id}/blast-radius`) const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
} }
export async function fetchEntity(id: string): Promise<Entity | null> { export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetch(`${API}/entities/${id}`) const res = await fetchWithAuth(`${API}/entities/${id}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
} }
@@ -521,7 +523,7 @@ export interface MetricSeries {
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> { export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -559,13 +561,13 @@ export interface RecentKnowledge {
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> { export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (source) params.set('source', source) 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: [] } if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
return res.json() return res.json()
} }
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> { export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
const res = await fetch(`${API}/knowledge/${entityId}`) const res = await fetchWithAuth(`${API}/knowledge/${entityId}`)
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -573,7 +575,7 @@ export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeH
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> { export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -581,7 +583,7 @@ export async function fetchEntityEvents(entityId: string): Promise<import('./sto
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> { export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -589,7 +591,7 @@ export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> { export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
const params = new URLSearchParams({ target: entityId, limit: '50' }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -611,16 +613,16 @@ export interface Check {
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> { export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
const params = new URLSearchParams({ target: targetSlug, limit: '50' }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
} }
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> { export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
const res = await fetch(`${API}/checks/${id}`, { const res = await fetchWithAuth(`${API}/checks/${id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` }, headers: { 'If-Match': `"${version}"` },
body: JSON.stringify(patch) body: JSON.stringify(patch)
}) })
if (!res.ok) return null 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.activity_type) params.set('activity_type', filters.activity_type)
if (filters.entity_id) params.set('entity_id', filters.entity_id) if (filters.entity_id) params.set('entity_id', filters.entity_id)
params.set('limit', String(filters.limit ?? 200)) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -662,7 +664,7 @@ export async function fetchAgentActivity(filters: {
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> { export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
const params = new URLSearchParams({ q, limit: String(limit) }) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []
@@ -698,7 +700,7 @@ export async function fetchAudit(filters: {
if (filters.action) params.set('action', filters.action) if (filters.action) params.set('action', filters.action)
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id) if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
params.set('limit', String(filters.limit ?? 200)) 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 [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return data.items ?? [] return data.items ?? []

92
web/src/lib/config.ts Normal file
View File

@@ -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<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {})
}
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)}`
}

View File

@@ -1,4 +1,5 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { sseUrl } from '$lib/config'
export interface OikosEvent { export interface OikosEvent {
id: number id: number
@@ -23,7 +24,7 @@ function connect() {
if (source) return if (source) return
connectionState.set('connecting') connectionState.set('connecting')
// The browser's EventSource sends Last-Event-ID automatically on reconnect. // 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') source.onopen = () => connectionState.set('open')

View File

@@ -1,6 +1,9 @@
import { mount } from 'svelte' import { mount } from 'svelte'
import App from './App.svelte' import App from './App.svelte'
import './app.css' import './app.css'
import { initConfig } from '$lib/config'
initConfig()
const app = mount(App, { target: document.getElementById('app')! }) const app = mount(App, { target: document.getElementById('app')! })
export default app export default app

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card'
import * as Tabs from '$lib/components/ui/tabs'
import { Input } from '$lib/components/ui/input'
import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
const existing = getConfig()
let apiUrl = $state(existing.apiUrl ?? '')
let token = $state(existing.token ?? '')
let connecting = $state(false)
let error = $state('')
function disconnect() {
clearConfig()
apiUrl = ''
token = ''
error = ''
}
async function connect() {
error = ''
if (!token.trim()) {
error = 'Token is required'
return
}
connecting = true
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
try {
const res = await fetchWithAuth('/api/v1/dashboard/summary')
if (!res.ok) {
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
return
}
onConnected()
} catch (e) {
error = 'Could not reach server — check the URL'
} finally {
connecting = false
}
}
</script>
<div class="flex h-svh items-center justify-center p-6">
<Card.Root class="w-full max-w-md">
<Card.Header>
<Card.Title>Connect to Oikos</Card.Title>
<Card.Description>Enter the server URL and your access token.</Card.Description>
</Card.Header>
<Card.Content>
<Tabs.Root value="token">
<Tabs.List class="mb-4 grid w-full grid-cols-2">
<Tabs.Trigger value="token">Token</Tabs.Trigger>
<Tabs.Trigger value="oidc" disabled>Login with Authentik (coming soon)</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="token">
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
<div class="flex flex-col gap-1.5">
<Label for="server-url">Server URL</Label>
<Input
id="server-url"
type="url"
placeholder="https://oikos.hubris.network (leave blank if same-origin)"
bind:value={apiUrl}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="token">Token</Label>
<Input id="token" type="password" placeholder="bearer token" bind:value={token} />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" disabled={connecting} class="flex-1">
{connecting ? 'Connecting…' : 'Connect'}
</Button>
{#if onCancel}
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
{/if}
</div>
{#if existing.token}
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
Forget saved connection
</Button>
{/if}
</form>
</Tabs.Content>
</Tabs.Root>
</Card.Content>
</Card.Root>
</div>

View File

@@ -2,9 +2,25 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from '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({ export default defineConfig({
plugins: [tailwindcss(), svelte()], plugins: [tailwindcss(), svelte()],
base: '/ui/', base: '/',
resolve: { resolve: {
alias: { $lib: '/src/lib' } alias: { $lib: '/src/lib' }
}, },
@@ -14,14 +30,11 @@ export default defineConfig({
}, },
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8090', '/api': authProxy('http://localhost:8090'),
// Production Caddy strips /agent before forwarding to nomos // Production Caddy strips /agent before forwarding to nomos
// (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that // (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that
// here so dev and prod agree on nomos's actual route paths. // here so dev and prod agree on nomos's actual route paths.
'/agent': { '/agent': authProxy('http://localhost:8092', (path) => path.replace(/^\/agent/, ''))
target: 'http://localhost:8092',
rewrite: (path) => path.replace(/^\/agent/, '')
}
} }
} }
}) })