# ADR 0015 — Bearer-token auth for every route + client/server split

Status: accepted (2026-07-12) · Plan: plans/2026-07-12-wails-desktop-app.md, Phase 0

## Context
The control-room SPA was embedded in the `oikos` binary (`go:embed`,
ADR 0001) and served at `/ui/*`. `combinedAuth` (`internal/httpapi/server.go`)
opened a dev-open bypass — no credential required at all — whenever
`OIKOS_ENV=dev` and no static token/OIDC issuer was configured. That was
true not just in local dev but in the actual mac-mini production deploy:
`docker-compose.yml`'s `api` service hardcoded `OIKOS_ENV: dev` with no
token set, so every route (`/api/v1/*`, `/mcp`, and an `/agent` reverse-proxy
mount to nomos that had never been wrapped in `combinedAuth` at all) was
reachable unauthenticated from anywhere on the mesh/LAN. A planned Wails
desktop client and any future non-browser client can't rely on same-origin
requests or a dev-open bypass; they need the SPA to be a standalone,
CORS-capable client that authenticates over HTTP like any other caller.

## Decision
- Delete the SPA embed (`web/embed.go`, the `/ui/*` routes). `web/` is a
  standalone static build, deployed separately (`make ui` / `make
  deploy-ui`), served at `/` by Caddy with SPA fallback.
- Remove the dev-open bypass entirely. Every route requires a valid
  static bearer token (`OIKOS_API_TOKEN` / `OIKOS_MCP_BEARER_TOKEN`) or an
  OIDC JWT, with two narrow exceptions: `/healthz` (liveness) and
  `POST /api/v1/clients/enroll` (IP-gated in the handler instead).
  `GET /api/v1/events/stream` additionally accepts the token as a
  `?token=` query param, since `EventSource` can't set custom headers.
- Add CORS (`github.com/go-chi/cors`, `OIKOS_CORS_ORIGIN`, default `*`) so a
  cross-origin SPA (Vite dev server, a future Wails webview) can reach the
  API. No `AllowCredentials` — auth is a header, not a cookie, so
  credentialed CORS mode isn't needed and the two don't combine safely with
  a wildcard origin.
- Wrap the previously-unauthenticated `/agent` proxy mount in the same
  `combinedAuth` middleware as every other route.
- `cmd/nomos` becomes an authenticated client of `api`: it now sends
  `Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN` on its own outbound calls
  (MCP + the chat-assent approval-decision endpoint), which it never did
  before — dev-open covered for it until now.
- The SPA gets a runtime config module (`web/src/lib/config.ts`) and a
  first-launch `Config.svelte` screen: server URL + token, stored in
  `localStorage`, injected into every `fetch()` via a shared
  `fetchWithAuth` wrapper. Resolved fresh per request (not cached at
  module-load time), so the same build works same-origin or cross-origin
  without a rebuild.

## Consequences
- Closing dev-open was a live security fix, not just future-proofing —
  verified post-deploy that unauthenticated requests to production now 401.
- Nomos's *own* HTTP gateway (`cmd/nomos`, port 8092) still has no auth of
  its own — out of scope here, tracked separately
  (plans/2026-07-11-nomos-agent-code-review.md, finding C1).
- Production Caddy (`dtoro/caddy-conf`, not this repo) does not yet expose
  `oikos.hubris.network` at all, so the interaction between Authentik
  forward-auth and bearer-token clients (a non-browser client can't
  complete a browser SSO redirect) is unresolved — needs an `@enroll`-style
  bypass for `/api/v1/*`/`/mcp`/`/agent/*` before public exposure. This
  repo's `compose/caddy/Caddyfile.oikos` (a reference copy, not deployed
  from here) has the bypass; the real config does not yet.
- There is one shared bearer secret for all agents/clients, not per-client
  tokens — acceptable for the current fleet size, revisit if per-client
  revocation becomes necessary.
