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