ADR-0040: Multi-daemon operator UI — runtime daemon registry, cross-origin hosting, bearer transport

  • Status: Accepted
  • Date: 2026-06-14
  • Deciders: @karasu, with colleagues
  • Supersedes:
  • Superseded by:
  • Amends: the build-time KAGED_UI_API_BASE cross-origin model (ui/README.md 2026-05-22 amendment); http-api.md open question #6 (CORS); ADR-0035 status strip (§4.5)
  • Prerequisite for: ADR-0041 (containerised daemon — the no-embedded-UI binary depends on the UI being independently hostable)

Context

Today the UI resolves its API base once at module load from the KAGED_UI_API_BASE build-time env var (resolveApiBase() in packages/ui/src/lib/api-client.ts), defaulting to the same-origin /api/v1. In production the daemon serves the compiled SPA; in dev Vite proxies /api to the daemon. Auth is cookie + CSRF: loopback (/launch token exchange), sharedsso (/auth/sso/callback JWT → POST /api/v1/auth/sso), or sidecar headers. The CSRF token is read from the GET /api/v1/me response body and held in a module-level variable.

The target deployment is different in kind:

  • The SPA is hosted once at ui.kaged.dev (static, CDN-fronted), decoupled from any daemon.
  • An operator runs one or more daemons at arbitrary hosts (homelab, daemon.example.com, behind a tunnel).
  • Bootstrapping a daemon is done by visiting the daemon's URL: it redirects to the UI, which registers the daemon and makes it active, then authenticates as before.
  • Once a second daemon is registered, a switcher (bottom-right) flips the active daemon. No daemon-to-daemon communication — deferred.

Three constraints shape the decision:

  1. ui.kaged.dev and a remote daemon are different sites. The existing cookie+CSRF model only works cross-site with SameSite=None; Secure cookies plus CORS-with-credentials. That path functions in Chromium but is blocked under Safari ITP and partitioned under Firefox Total Cookie Protection by default — i.e. it is fragile for exactly the remote-daemon case being built, independent of any one browser's third-party-cookie roadmap.
  2. The co-located and dev paths must not regress. A single daemon serving its own SPA at the same origin, and the devctl Vite-proxy loopback flow, must continue to work with zero configuration and no visible switcher.
  3. The daemon base is now a runtime value, not a build constant. It drives both REST (API_BASE) and the WebSocket URL, and it changes when the operator switches daemons.

Decision

The active daemon base becomes a runtime value sourced from a persisted, operator-owned daemon registry. Cross-origin hosting is first-class. Authentication across the registry uses a daemon-minted bearer token (one transport), not cross-site cookies. A daemon advertises an explicit public base URL and bootstraps the UI via a /connect redirect.

Concretely:

1. Daemon registry (UI, persisted)

A Zustand store useDaemonRegistry, persisted to localStorage, matching the existing persistence pattern. Shape:

type DaemonEntry = {
  id: string;          // ULID, stable across relabels
  apiBase: string;     // normalized, e.g. "https://daemon.example.com/api/v1"
  label: string;       // operator-editable; defaults to apiBase host
  sessionToken?: string; // daemon-minted bearer; absent until authenticated
  addedAt: number;
};
type RegistryState = { daemons: DaemonEntry[]; activeId: string | null };
  • Entries dedupe on normalized apiBase (trailing slashes stripped, lowercased host).
  • KAGED_UI_API_BASE (or /api/v1) is demoted to a seed: when the registry is empty, exactly one entry is synthesized from it and marked active. This preserves the co-located/dev path verbatim.
  • resolveApiBase() returns the active entry's apiBase. API_BASE ceases to be a module const; every call site resolves through the store.
  • The WebSocket URL is derived from the active apiBase (http→ws / https→wss swap), never from window.location.

2. Bearer transport (one transport, daemon + UI)

The cross-site cookie path is rejected (see Alternatives A). Instead:

  • The daemon's auth resolver accepts Authorization: Bearer <session_token> as a first-class identity path alongside the existing cookie/sidecar paths. No handler below the gate branches on how the principal authenticated (consistent with ADR-0036's invariant).
  • GET /api/v1/launch (JSON mode) and POST /api/v1/auth/sso return a session_token in the response body in addition to (loopback) or instead of (cross-origin) setting the cookie. This token is the bearer credential.
  • The UI stores session_token on the registry entry and sends it as Authorization: Bearer for that daemon. Because there is no ambient cookie, CSRF protection is unnecessary in the bearer path; X-Kaged-CSRF and the CSRF cookie are retained only for the same-origin cookie fallback.
  • Same-origin co-located mode MAY continue to use cookies (the seed entry has no sessionToken, so the client falls back to cookie+CSRF). Remote/registry entries always use bearer.

3. Explicit advertised base + bootstrap redirect (daemon)

  • New daemon config daemon.public_url (env KAGED_PUBLIC_URL) — the browser-reachable origin of this daemon, e.g. https://daemon.example.com. Behind a tunnel the daemon cannot reliably infer this, so it is explicit; Host + X-Forwarded-Proto is used only as a fallback when public_url is unset.
  • An unauthenticated HTML GET / (i.e. Accept: text/html) 302s to ${ui_url}/connect?api=${public_url}/api/v1 when ui_url is configured (split mode). When ui_url is empty (co-located mode), the daemon serves the SPA as it does today.
  • The printed loopback launch URL changes from ${ui_url}/launch?token=… to ${ui_url}/connect?api=${public_url}/api/v1&token=…, so a single URL both registers the daemon and authenticates it.

4. /connect route (UI)

A new standalone route (peer to /launch and /auth/sso/callback, outside the AppShell and the auth gate):

  1. Reads api (required) and optional token.
  2. Registers/updates the daemon entry for api and sets it active.
  3. Hands off to the existing auth entry against that base: if token is present, runs the /launch exchange and stores the returned session_token; otherwise runs GET /api/v1/auth/methods discovery (loopback hint / password / sharedsso provider buttons), with sharedsso return pointed at ui.kaged.dev/auth/sso/callback.

5. Switcher (UI, status strip)

A dropdown anchored bottom-right in the thin status strip (ADR-0035 §4.5), beside the version/state block.

  • Hidden when the registry has ≤ 1 entry.
  • Lists entries by label with a state dot; supports add (opens a paste-the-daemon-URL affordance), relabel, and remove.
  • Switching active daemon tears down the system socket and all session sockets, clears the react-query cache, and re-runs useMe() against the new base before any new request is issued.

Implementation — phased

Phase 0 — Registry + runtime base (UI, no behavior change for co-located)

  • useDaemonRegistry Zustand store with localStorage persistence; seed-on-empty from KAGED_UI_API_BASE / /api/v1.
  • Rewrite resolveApiBase() to read the active entry; remove the module-level API_BASE const; route all apiGet/Post/Put/Patch/Delete/apiCall and buildRouteUrl through it.
  • WS URL builder derives from active apiBase (scheme-swap), not window.location.
  • ⛔ invariant: with an empty/seed registry, the co-located and devctl loopback paths behave byte-identically to pre-ADR (asserting test: seed entry == prior API_BASE; switcher not rendered).

Phase 1 — Bearer transport (daemon)

  • Auth resolver accepts Authorization: Bearer <session_token>; resolves the same identity the cookie path would.
  • getLaunch (JSON mode) and the /auth/sso handler include session_token in the body.
  • ⛔ invariant: a handler below the gate cannot observe which transport authenticated the principal (asserting test mirrors ADR-0036 Phase 2).
  • ⛔ invariant: a session_token minted by daemon A is rejected by daemon B (token scoping asserted).

Phase 2 — Bearer transport (UI)

  • Store session_token on the active entry after /launch / /auth/sso.
  • headers() emits Authorization: Bearer when the active entry has a token; CSRF header/cookie only on the tokenless same-origin path.
  • ⛔ invariant: the client never sends entry A's sessionToken to entry B's apiBase (asserting test).

Phase 3 — Advertised base + bootstrap redirect (daemon)

  • daemon.public_url config + KAGED_PUBLIC_URL override; Host/X-Forwarded-Proto fallback.
  • Unauth HTML GET / 302s to ${ui_url}/connect?api=… when ui_url set; serves SPA when unset.
  • Loopback launch URL rewritten to the /connect?api=…&token=… form.
  • CORS for the configured ui_url origin: Access-Control-Allow-Origin echoes the ui_url origin; preflight (OPTIONS) allows Authorization, Content-Type, X-Kaged-CSRF. Access-Control-Allow-Credentials only on the cookie fallback path.
  • Startup self-check: if ui_url is set and the bind is non-loopback, public_url is required (refuse to start otherwise), paralleling the sidecar self-check.
  • ⛔ invariant: with ui_url set, the daemon never serves the SPA from / (asserting test).

Phase 4 — /connect route (UI)

  • Register-and-activate + auth handoff (token passthrough or methods discovery).
  • ⛔ invariant: /connect registers exactly one entry per normalized api (no duplicates on repeat visits).

Phase 5 — Switcher (UI)

  • Status-strip dropdown; add/relabel/remove; hidden at ≤ 1 entry.
  • Switch performs socket teardown + query-cache reset + useMe() refetch before any request to the new base.
  • ⛔ invariant: no query result from daemon A is served after a switch to daemon B (asserting test on cache reset ordering).

Phase 6 — Docs

  • Spec amendments per the checklist below; STATUS.md synced in the same diff.

Amendment checklist (specs)

  • docs/specs/ui/README.md — registry model; /connect screen; bearer transport; KAGED_UI_API_BASE demoted to seed; switcher behavior; auth-gate exemption for /connect.
  • docs/specs/ui/app-shell.md — switcher placement in the status strip (§4.5); state-persistence keys for registry/active selection.
  • docs/specs/http-api.md — resolve open question #6 (CORS): per-origin allowlist + bearer; /launch and /auth/sso return session_token; advertised-base semantics. Note /connect is a UI route, not an API endpoint.
  • docs/specs/daemon.mddaemon.public_url config; bootstrap redirect + root behavior; startup self-check.
  • Config schema (daemon TOML, [daemon]) gains public_url. ([ui].url / KAGED_UI_URL unchanged.)

Consequences

Easier

  • One auth transport across every deployment topology; CSRF concerns largely dissolve in the bearer path.
  • UI ships once and points anywhere; daemon and UI release cadence fully decouple.
  • The daemon binary (ADR-0041) need not embed the SPA in the split topology — smaller artifact, and the protected surface excludes the (already-public) UI.

Harder / accepted costs

  • session_token lives in localStorage, exposing it to XSS. This is the explicit tradeoff against cross-site cookie fragility; mitigated by short token TTL and the operator-trust model (see open questions).
  • The oauth2-proxy sidecar fronting pattern is cookie-coupled at its own edge; a sidecar-fronted daemon on a different site from ui.kaged.dev inherits the cross-site cookie problem at the proxy layer, which is outside kaged's control. The blessed remote path is therefore sharedsso, not sidecar (see open questions).
  • Two auth fallbacks coexist (bearer + same-origin cookie). Kept deliberately minimal: cookie only on the seed/same-origin entry.

Alternatives considered

Alternative A — SameSite=None; Secure cookies + CORS-with-credentials

The smallest diff: keep cookies, set SameSite=None, echo the UI origin with Access-Control-Allow-Credentials: true. Rejected because it is blocked/partitioned by default in Safari and Firefox for cross-site contexts, so the remote-daemon experience would silently break for a large fraction of operators regardless of Chromium's roadmap. Bearer-in-header is sent explicitly and is unaffected by cookie-partitioning policy.

Alternative B — per-daemon build-time bundles

Bake KAGED_UI_API_BASE per daemon and host each at its own UI origin. Rejected — defeats the single ui.kaged.dev goal and makes multi-daemon switching impossible without N origins.

Alternative C — daemon-to-daemon proxy / aggregator

A "home" daemon proxies to the others so the UI talks to one origin. Rejected for now — that is the daemon-to-daemon work explicitly deferred; it also reintroduces a trust-bridging surface the registry avoids.

Open questions

  1. Token storage. localStorage (persistent, XSS-exposed) vs sessionStorage (per-tab, lost on close) vs in-memory + silent re-auth on reload. Leaning localStorage for operator ergonomics; revisit if the XSS surface widens.
  2. Token lifetime / refresh. SSO JWTs are short-lived; the daemon-minted session_token is presumably longer. Define TTL and whether refresh is silent (re-hit /auth/sso against a relay cookie) or requires re-launch.
  3. Sidecar as a remote fronting. Confirm whether oauth2-proxy sidecar is supported only for same-site/co-located daemons, with sharedsso as the sole blessed remote transport — or whether a sidecar-bearer bridge is worth specifying.
  4. Registry portability. The registry is per-browser. Cross-browser sync / export-import is out of scope here; note the relationship (if any) to ADR-0011 project portability.
  5. Liveness in the switcher. Probe entries lazily (on switch) vs eagerly (show dead daemons). Leaning lazy for v0.

Resolution log

2026-06-14 — Q1 + Q2 resolved (bearer token format, scoping, TTL)

Resolved before implementation. The session_token is a versioned opaque token validated against the daemon's existing authoritative session state — no JWT/HMAC machinery and no schema change:

  • Format. kaged.v1.lb.<secret> for the loopback transport, kaged.v1.us.<sessionId> for the SSO transport. Treated as opaque by the UI; the prefix is server-side routing only.
  • Validation. The loopback bearer's <secret> is compared timing-safe against the in-process loopback session secret (the same value behind the kaged_session cookie). The SSO bearer's <sessionId> is looked up in user_sessions and subject to that row's existing expiry/revocation rules. user_sessions.sessionId is CSPRNG-generated (crypto.getRandomValues), so exposing it as a bearer is safe.
  • Scoping (Invariant A). A loopback token only matches the minting daemon's per-process secret; an SSO token only resolves against the minting daemon's local user_sessions table. Daemon B therefore rejects daemon A's token. This holds only while user_sessions is daemon-local — if session storage is ever shared/centralised across daemons, this must move to a daemon-signed wrapper with a per-daemon secret.
  • Gate placement (Invariant B). The gate resolves an internal { identity, transport }. Bearer is tried after insecure/sidecar and before the cookie fallback. If Authorization is present but malformed/invalid, the gate returns 401 and does not fall back to cookies. Only server.ts reads transport (for the CSRF decision); handlers receive only identity.
  • CSRF. Skipped iff transport === "bearer". The cookie/sidecar/insecure paths are byte-identical to pre-ADR when no Authorization header is present.
  • TTL / refresh. The loopback bearer is valid for the daemon process lifetime only; a daemon restart rotates the secret and invalidates it — the UI clears the token on 401 and re-runs /connect's launch exchange. The SSO bearer inherits the existing 30-day user_sessions.expiresAt; on expiry/revocation the UI re-runs the SSO flow. No separate daemon refresh endpoint is added.
  • Insecure mode. The bearer path is inert: identity is header-derived and CSRF is already skipped, so Authorization neither helps nor harms.
  • URL safety. Only the single-use, immediately-rotated launch token ever appears in a URL (/connect?api=…&token=…). The session_token is body-only and never logged.

Q3–Q5 remain open and out of scope for this implementation (sharedsso is the blessed remote transport; registry is per-browser; switcher liveness is lazy).