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_BASEcross-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:
ui.kaged.devand a remote daemon are different sites. The existing cookie+CSRF model only works cross-site withSameSite=None; Securecookies 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.- The co-located and dev paths must not regress. A single daemon serving its own SPA at the same origin, and the
devctlVite-proxy loopback flow, must continue to work with zero configuration and no visible switcher. - 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
/connectredirect.
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'sapiBase.API_BASEceases 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 fromwindow.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) andPOST /api/v1/auth/ssoreturn asession_tokenin 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_tokenon the registry entry and sends it asAuthorization: Bearerfor that daemon. Because there is no ambient cookie, CSRF protection is unnecessary in the bearer path;X-Kaged-CSRFand 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(envKAGED_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-Protois used only as a fallback whenpublic_urlis unset. - An unauthenticated HTML
GET /(i.e.Accept: text/html) 302s to${ui_url}/connect?api=${public_url}/api/v1whenui_urlis configured (split mode). Whenui_urlis 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):
- Reads
api(required) and optionaltoken. - Registers/updates the daemon entry for
apiand sets it active. - Hands off to the existing auth entry against that base: if
tokenis present, runs the/launchexchange and stores the returnedsession_token; otherwise runsGET /api/v1/auth/methodsdiscovery (loopback hint / password / sharedsso provider buttons), with sharedssoreturnpointed atui.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
labelwith 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)
-
useDaemonRegistryZustand store withlocalStoragepersistence; seed-on-empty fromKAGED_UI_API_BASE//api/v1. - Rewrite
resolveApiBase()to read the active entry; remove the module-levelAPI_BASEconst; route allapiGet/Post/Put/Patch/Delete/apiCallandbuildRouteUrlthrough it. - WS URL builder derives from active
apiBase(scheme-swap), notwindow.location. - ⛔ invariant: with an empty/seed registry, the co-located and
devctlloopback paths behave byte-identically to pre-ADR (asserting test: seed entry == priorAPI_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/ssohandler includesession_tokenin 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_tokenminted by daemon A is rejected by daemon B (token scoping asserted).
Phase 2 — Bearer transport (UI)
- Store
session_tokenon the active entry after/launch//auth/sso. -
headers()emitsAuthorization: Bearerwhen the active entry has a token; CSRF header/cookie only on the tokenless same-origin path. - ⛔ invariant: the client never sends entry A's
sessionTokento entry B'sapiBase(asserting test).
Phase 3 — Advertised base + bootstrap redirect (daemon)
-
daemon.public_urlconfig +KAGED_PUBLIC_URLoverride;Host/X-Forwarded-Protofallback. - Unauth HTML
GET /302s to${ui_url}/connect?api=…whenui_urlset; serves SPA when unset. - Loopback launch URL rewritten to the
/connect?api=…&token=…form. - CORS for the configured
ui_urlorigin:Access-Control-Allow-Originechoes theui_urlorigin; preflight (OPTIONS) allowsAuthorization,Content-Type,X-Kaged-CSRF.Access-Control-Allow-Credentialsonly on the cookie fallback path. - Startup self-check: if
ui_urlis set and the bind is non-loopback,public_urlis required (refuse to start otherwise), paralleling the sidecar self-check. - ⛔ invariant: with
ui_urlset, 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:
/connectregisters exactly one entry per normalizedapi(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.mdsynced in the same diff.
Amendment checklist (specs)
-
docs/specs/ui/README.md— registry model;/connectscreen; bearer transport;KAGED_UI_API_BASEdemoted 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;/launchand/auth/ssoreturnsession_token; advertised-base semantics. Note/connectis a UI route, not an API endpoint. -
docs/specs/daemon.md—daemon.public_urlconfig; bootstrap redirect + root behavior; startup self-check. - Config schema (daemon TOML,
[daemon]) gainspublic_url. ([ui].url/KAGED_UI_URLunchanged.)
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_tokenlives inlocalStorage, 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.devinherits 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
- Token storage.
localStorage(persistent, XSS-exposed) vssessionStorage(per-tab, lost on close) vs in-memory + silent re-auth on reload. LeaninglocalStoragefor operator ergonomics; revisit if the XSS surface widens. - Token lifetime / refresh. SSO JWTs are short-lived; the daemon-minted
session_tokenis presumably longer. Define TTL and whether refresh is silent (re-hit/auth/ssoagainst a relay cookie) or requires re-launch. - 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.
- 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.
- 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 thekaged_sessioncookie). The SSO bearer's<sessionId>is looked up inuser_sessionsand subject to that row's existing expiry/revocation rules.user_sessions.sessionIdis 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_sessionstable. Daemon B therefore rejects daemon A's token. This holds only whileuser_sessionsis 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. IfAuthorizationis present but malformed/invalid, the gate returns 401 and does not fall back to cookies. Onlyserver.tsreadstransport(for the CSRF decision); handlers receive onlyidentity. - CSRF. Skipped iff
transport === "bearer". The cookie/sidecar/insecure paths are byte-identical to pre-ADR when noAuthorizationheader 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-dayuser_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
Authorizationneither helps nor harms. - URL safety. Only the single-use, immediately-rotated launch token ever appears in a URL (
/connect?api=…&token=…). Thesession_tokenis 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).