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
|
.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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
1
go.mod
@@ -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
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/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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 +264,10 @@ 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"
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
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",
|
"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": {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
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 { 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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 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/, '')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user