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