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

Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:49:42 +02:00
parent 346eb2f144
commit 0c0f35a3a9
32 changed files with 661 additions and 248 deletions

View File

@@ -4,6 +4,16 @@
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
`relationship.ended` API calls don't emit `observability.Event`, and
trusted-proxy header auth for Authentik was never added to `combinedAuth`).
**Superseded (2026-07-12):** the embed architecture below (`go:embed
all:web/dist`, served at `/ui/`) was removed —
[2026-07-12-wails-desktop-app.md](2026-07-12-wails-desktop-app.md) Phase 0
separates the SPA from the `oikos` binary into a standalone static build,
served at `/` (no `/ui/` prefix), talking to the API over bearer-token
auth (the dev-open bypass mentioned nowhere in this plan was also removed).
The trusted-proxy-header gap noted above is moot under the new model — every
route requires a real bearer token regardless of what's in front of it. M1-M3
and the SPA/component work below are unaffected; only the packaging and auth
sections are stale.
N0-N3 (Nomos amendment: chat home + sessions), M1
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
component system), M2 (Operations ledger with approve/deny + cancel, Signals

View File

@@ -1,6 +1,119 @@
# 2026-07-12 — Wails desktop application
**Status:** Planned — not started
**Status:** In Progress — Phase 0 (0.1-0.4, 0.6) done and verified live
(browser: cross-origin static SPA + API on different ports, CORS, bearer
auth, SSE query-token auth, localStorage persistence across reload — see
"Plan review" for the gaps found and fixed along the way). Phase 1 (Wails
shell) not started.
## Plan review — gaps found before starting Phase 0
Reviewed against the current codebase and the live Caddy topology
(`compose/caddy/Caddyfile.oikos`) before writing any code. Six gaps, each
with the resolution taken:
1. **Authentik forward-auth vs. bearer-token clients.** The deployed
`oikos.hubris.network` site gates every route (including `/agent/*` and,
after this plan, `/api/v1/*`) with `import authentik` — a browser-session
forward-auth check, not a header a non-browser client can supply. Closing
the dev-open gate (0.4) makes every API route require a bearer token, but
says nothing about how a bearer-token client (Wails, curl, a future mobile
client) gets past Authentik's login redirect in front of it. Same shape as
the existing `@enroll` bypass for `/api/v1/clients/enroll`.
**Resolution:** updated the reference copy
([Caddyfile.oikos](compose/caddy/Caddyfile.oikos)) with an `@api path
/api/v1/* /mcp /agent/*` bypass around `import authentik`, same pattern as
`@enroll`, and moved static-SPA serving into the `handle {}` fallback
(0.6). This repo's copy is not what's deployed — the real file lives in
`dtoro/caddy-conf` and auto-deploys from there — so the equivalent change
still needs to land there before a Wails client (or anything else that
can't complete Authentik's browser login) can actually reach the API in
production. Flagged explicitly as risk #6 below so it isn't discovered the
hard way.
2. **Nomos's own gateway (C1) is a parallel, unauthenticated path to the same
backend.** [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)'s
C1 finding — nomos's port 8092 has zero auth of its own — is still open.
Phase 0.3's CORS/auth work only touches `internal/httpapi` (the `api`
process); `cmd/nomos` is untouched. The architecture diagram in this plan
shows Caddy's `handle_path /agent/*` proxying straight to `:8092`,
bypassing `api`'s `combinedAuth` entirely and relying solely on Authentik.
Closing the API's dev-open gate does nothing for this path — nomos's
direct mesh-published port (`docker-compose.yml:133`) and
`nomos.hubris.network` remain reachable with no credential check at all.
**Resolution:** not fixed by this plan — flagged as a pre-existing,
independent gap (already tracked as C1) that the Wails desktop app
inherits rather than introduces. Added as risk #6 below so it isn't
mistaken for something Phase 0 closes.
3. **`github.com/go-chi/cors` isn't a dependency yet**, and the plan's sample
CORS config (`AllowCredentials: true` with a default `"*"` origin) is
spec-invalid — browsers and webviews reject a wildcard
`Access-Control-Allow-Origin` when credentials are requested. This API
authenticates via `Authorization: Bearer`, not cookies, so credentialed
CORS mode isn't needed at all. **Resolution:** drop `AllowCredentials`
from the middleware config in 0.3 rather than ship a setting that silently
breaks the first time an origin other than `*` is configured.
4. **Closing dev-open (0.4) breaks local `docker compose --profile dev up`
out of the box** — none of the compose services currently set a token, and
today they rely entirely on `OIKOS_ENV=dev` + devOpen. Worse: `cmd/nomos`
itself is an unauthenticated client of `api`'s `/mcp` endpoint and
`/api/v1/approvals/{id}/decision` (chat-assent approvals) —
`grep -rn "Authorization" cmd/nomos/*.go` returned nothing before this
fix. Closing dev-open without touching nomos would have broken nomos's own
connection to the API, not just local dev ergonomics; this wasn't called
out anywhere in the original plan text. **Resolution:** added a `token`
field threaded through `mcpClient`/`mcpClientPool` and `agent.apiToken`,
both reading `OIKOS_MCP_BEARER_TOKEN` (the same shared secret `api`
already validates static tokens against) and sent as `Authorization:
Bearer ...` on every request nomos makes to `api`. `docker-compose.yml`
sets `OIKOS_MCP_BEARER_TOKEN` (default `dev-token`) on both the `api` and
`nomos` services so local dev keeps working.
5. **0.2's `const API = apiBase('/api/v1')` pattern bakes in a stale origin.**
Module-level constants evaluate once, at import time — before
`main.ts`'s `initConfig()` runs (ES module imports are hoisted ahead of a
file's own top-level statements) and before `Config.svelte` or a
Wails-injected `window.__OIKOS_CONFIG__` can set `apiUrl`. A first-launch
Wails webview would resolve `API` to a relative path and try to fetch
`wails://.../api/v1/...`, which doesn't exist. **Resolution:** `api.ts`
keeps `BASE`/`API` as bare path prefixes (`/agent`, `/api/v1`, never
resolved to a URL) and lets `fetchWithAuth` call `apiBase()` fresh on
every request — the same fix pattern as gap 4's SSE snippet: resolve at
call time, not at module-load time.
6. **`api`'s own `/agent` reverse-proxy mount (to nomos) was never behind
`combinedAuth` — found while auditing every route for the dev-open
removal.** [server.go](../internal/httpapi/server.go)'s
`r.Mount("/agent", ...)` was registered directly on the base router,
unlike every other custom route (`/mcp`, `/api/v1/knowledge/recent`,
etc.), which all use `r.With(combinedAuth(cfg, false))`. Harmless while
dev-open made the whole API open anyway; a real hole the moment 0.4 closes
it — any request to `api`'s `/agent/*` would reach nomos with no
credential check at all, independent of C1 (nomos's *own* gateway on
:8092, still open) and independent of gap 1 (Caddy/Authentik). **Resolution:**
wrapped the mount in `combinedAuth(cfg, false)`, matching every other
route.
Also: 0.4's local-dev token delivery ended up simpler than described —
"Vite injects it into `window.__OIKOS_CONFIG__` at dev time" isn't needed at
all for the relative-path dev case. The Vite proxy (0.2) already injects
`Authorization: Bearer $OIKOS_API_TOKEN` server-side on every proxied
`/api`/`/agent` request, so relative-path fetches during `npm run dev` are
authenticated before they leave the dev server — no client-side config
needed. `window.__OIKOS_CONFIG__` injection is still exactly what Phase 1's
Wails shell needs (absolute URL, no dev proxy to lean on).
Also: 0.3's SSE-auth snippet checks `GetActor(r.Context()) == nil` *inside*
`serveSSE` and validates the query token there — but `serveSSE` only runs
after `combinedAuth` has already accepted or rejected the request, and
`combinedAuth` requires a header today, so `EventSource` requests (no custom
headers) never reach `serveSSE` at all; they 401 in the middleware first.
**Actual implementation:** `combinedAuth` itself takes an `allowQueryToken
bool`; when set (only for the `/api/v1/events/stream` route) it falls back to
`?token=` when the `Authorization` header is absent, before running the same
OIDC/static validation as every other route. This reuses all existing auth
logic instead of duplicating a static-token-only path inside `serveSSE`, and
keeps the gate at the middleware layer rather than half-open inside the
handler. The static-token comparison itself was extracted into
`staticTokenActor(cfg, raw)`, shared between the header and query-param
paths.
## Goal
@@ -676,3 +789,18 @@ connect to the homelab → full app works with zero dev tools.
handles multiple subscribers (fan-out via the subscriber list in
`sse.go`). Each client gets its own connection and replay. No change
needed.
6. **Deploy-time Caddy changes this plan does not make.** Two changes are
required outside this repo before Phase 0's auth tightening actually
protects anything in production, both in `dtoro/caddy-conf`:
- Add a bearer-token bypass around `import authentik` for `/api/v1/*` and
`/mcp` on `oikos.hubris.network`, mirroring the existing `@enroll`
bypass — otherwise closing the dev-open gate just adds a second,
redundant auth layer behind Authentik's browser-session check, and
non-browser clients (Wails, curl) can never get past the first one.
- Nomos's gateway (port 8092) has no auth of its own (C1, tracked in
[2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)).
Phase 0 does not fix this — the mesh-published port and
`nomos.hubris.network` remain open regardless of anything done here.
Treat C1 as a co-requisite for a production Wails rollout, not
something this plan's auth work incidentally covers.

View File

@@ -14,7 +14,7 @@ went sideways, open an investigation.
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | Planned — not started |
| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | In Progress — Phase 0 done, Phase 1 not started |
## Done