# 0034: Dashboard browser authentication is host-owned and principal-first - Status: Accepted - Date: 2026-08-23 - Amends: [0011](0011-identity-http-transport-and-public-enrollment.md) - Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), [0027](0027-dashboard-navigation-and-flat-modules.md) ## Context Ekklesia already had email/password authentication, an HttpOnly session cookie, a minimal `GET /api/v1/principal` contract, authorization middleware, and a Dashboard shell. The shell also already resolved the principal before requesting protected navigation. What it did not have was a browser path for an anonymous person to become authenticated or for an authenticated person to end their session. An anonymous bootstrap stopped at a message telling the viewer to sign in, while no surface drove the existing authentication endpoints. Closing that gap affects more than a form. A module UI may have an active API request or realtime subscription when a session expires or a person signs out. Better Auth's successful email sign-in response also includes a session token and user object in JSON even though the browser session is already represented by an HttpOnly cookie. A browser flow must not expose or adopt that redundant token, turn every authorization denial into logout, create an open-redirect input, or move platform authentication into a feature module. The display host adds a second important boundary. It is permission-gated today, but unattended device identity is explicitly deferred by ADR 0033. Adding Dashboard sign-in must not silently make the display application a device enrollment flow or add Dashboard chrome to its bundle. ## Decision ### The host owns authentication UX; modules retain the host-neutral context `surfaces/shell` owns the Dashboard's sign-in form, pending and failure states, sign-out control, session-transition behavior, and credential submission. A module never renders the platform login, imports an authentication client, receives a session token, or chooses how the host authenticates. The public module UI contract remains exactly the five-member, host-neutral `ModuleUiHostContext`: `moduleId`, `api`, `principal`, `permissions`, and `realtime`. No sign-in, sign-out, credential, cookie, token, or Ekklesia-specific session callback is added to it. The host may observe authorization responses from the API client it supplies, but that observation remains host-private and the module still receives the original response. This keeps one module artifact runnable under Ekklesia and a future standalone host. A standalone application will own its authentication experience just as the Ekklesia Dashboard owns this one; the module will continue to see only the same narrow principal and permission hints. ### Dashboard bootstrap remains principal-first The Dashboard resolves `GET /api/v1/principal` before requesting navigation or rendering any module UI: ```text loading -> anonymous -> host-owned login -> authenticated -> protected Dashboard navigation -> selected module -> unavailable/invalid -> sanitized failure with explicit retry ``` An anonymous principal is an ordinary, successful identity result, not an application failure. A principal infrastructure failure is not anonymous and does not reveal a login form that would misrepresent the outage as a missing session. No protected Dashboard request is made and no module code is loaded until the authenticated branch is established. After a successful sign-in mutation, the host does not trust an authentication response as a principal. It resolves `/api/v1/principal` again and only then loads Dashboard navigation. This changes the rendered state in place; a full page reload is not required. ### Email/password uses the native endpoint behind two narrow browser-safe exceptions The browser submits JSON credentials with `credentials: "include"` to Better Auth's existing native endpoint: ```text POST /api/auth/sign-in/email ``` ADR 0011 made normal Better Auth responses pass through the Core identity runtime unchanged. That remains true inside Core. This decision introduces exactly two browser-facing exceptions at the `apps/api` transport boundary: 1. When the exact email sign-in endpoint returns any body-bearing successful `2xx`, `apps/api` preserves that status and the headers — including `Set-Cookie` — but replaces the body with the fixed response below. 2. The token-bearing native session inspection/mutation paths `/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and `/api/auth/change-password` are unavailable. The API returns the same sanitized `404 {"code":"NOT_FOUND"}` as an unsupported route without invoking Better Auth. Password/account management is outside this pass; the pinned change-password route can return a new raw token when it revokes other sessions, so it cannot be left as an unused bypass. ```json { "ok": true } ``` Sign-in failures and every other supported Better Auth endpoint keep their existing response semantics. These exceptions are not a second authentication protocol: supported requests still reach Better Auth, Better Auth still creates and invalidates the session, and its HttpOnly cookie remains the only browser session authority. The normalization discards the redundant session token and user object before browser application JavaScript can read them. The denylist prevents a caller from bypassing the narrow Ekklesia identity contract to inspect or mutate native session objects. The supported browser identity read is exclusively `GET /api/v1/principal`; the shell treats `{ "ok": true }` only as permission to re-resolve it and does not persist, log, or derive identity from the body. The form exposes only email and password. It is a semantic browser form with labelled inputs, `username` and `current-password` autocomplete hints, keyboard submission, one in-flight attempt, and a visible pending state. A completed attempt clears the password while retaining the email for correction. Credential rejection has one generic message; service and malformed-response failures have a separate fixed generic message. Raw response bodies, caught exceptions, credentials, and request URLs never reach the DOM or console. ### Sign-out invalidates first, then asks the principal authority The host submits an empty JSON object with `credentials: "include"` to the existing endpoint: ```text POST /api/auth/sign-out ``` After the sign-out mutation succeeds, the host resolves `/api/v1/principal` instead of assuming that hiding the Dashboard equals logout. An anonymous result shows the login form. If the sign-out request throws or the subsequent principal resolution fails, protected UI stays removed and the host shows a sanitized failure with actions to retry sign-out or explicitly rebootstrap. It neither claims that the session ended nor automatically restores an authenticated state it has not revalidated. While the transition is in progress, the authenticated Dashboard subtree is removed. The generic module loader therefore invokes the selected module's existing unmount cleanup, and the module's host-owned realtime subscriptions close through their ordinary cleanup path. No module-specific logout event or teardown API is introduced. ### Platform bootstrap and module responses use different status policies A `401` from a platform-owned bootstrap request is an authentication challenge in the host's own fixed contract. The platform request helper maps it directly to `HOST_UNAUTHENTICATED`, and the principal-first Dashboard returns to login without treating it as an arbitrary module response. A platform navigation `403` remains an access denial and receives the existing principal recheck so the host can distinguish an authenticated denial from a session that expired between the principal and navigation requests. A module owns its response semantics and may return `401` or `403` for reasons that do not invalidate the host session. Either status observed through the module API facade therefore triggers one deduplicated `/api/v1/principal` revalidation while the module still receives its original response: - anonymous means the session ended, so the host unmounts the active module and shows login; - authenticated means the session and mounted module remain in place and the original module response remains that module's result; - an infrastructure failure becomes a sanitized retryable host failure, never a fabricated logout. A burst of module denials shares the in-flight principal revalidation. A response retained by a module frame that has already unmounted cannot start or overwrite a newer auth transition. A network error, malformed response, `404`, or `5xx` never clears authentication merely because it occurred. Realtime retains ADR 0032's native reconnect and five-minute server reauthorization; when a verified anonymous principal moves the host out of the authenticated state, normal module unmount closes its EventSource. ### There is no return URL or redirect sink in this pass Login renders at the current Dashboard URL and successful authentication replaces that state in place. The host accepts no `return`, `returnTo`, `callbackURL`, or equivalent request/query value, passes no callback URL to Better Auth, and performs no script-driven navigation. External, protocol-relative, backslash-prefixed, or scheme-bearing input therefore has no path to become a redirect target. A future client router may define an internal return-location contract, but it must be a separately validated, same-origin route decision. This pass does not create one speculatively. ### Cookie and origin policy does not broaden for the Dashboard The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax, Secure when the canonical identity base URL uses HTTPS, Path `/`, and no `Domain` attribute. It is host-only. Ekklesia does not broaden it to a parent domain merely so sibling subdomains can share a session. The simplest production shape is a Dashboard whose `/api` traffic is reverse-proxied on the same origin. A deliberately split browser/API origin remains possible only through the exact canonical trusted-origin allowlist and credentialed CORS already defined by ADR 0011, ADR 0012, and ADR 0018. Wildcards remain invalid. Better Auth's origin and CSRF checks remain explicitly enabled in every environment; the browser supplies its real `Origin` naturally rather than manufacturing or weakening it. The repository still provides no production Dashboard hostname, DNS, TLS, reverse-proxy rule, Content-Security-Policy, or static-asset deployment. Host-only cookies mean any eventual choice to place the API and Dashboard on different sibling subdomains must be designed and tested rather than assumed to share a session. ### Enrollment, recovery, throttling, and first ownership remain explicit gaps The login page is for an existing account. It contains no create-account link because public signup is disabled by default and prohibited in production by ADR 0017. It contains no forgot-password or verification flow because Ekklesia has no email sender, recovery delivery, or verified-email contract. Their absence is visible product scope, not a hidden link to a non-operational endpoint. Better Auth's sign-in limiter remains enabled with its strict per-path production rule of three attempts per ten seconds. Its store is in-memory and per API process: counters do not span multiple instances and do not survive restart. The server-owned client-address boundary prevents a caller from choosing a new bucket with a spoofed forwarding header, but it does not turn this into a shared or durable brute-force defense. A multi-instance or publicly launched deployment still needs a proven shared or edge limiter. The platform-owner bootstrap command still accepts only an **existing** Ekklesia user ID. Because production public signup is prohibited and no invitation or operator-owned user-creation command exists, this repository does not yet provide a supported way to create the first production identity that bootstrap needs. Tests may create a development identity, bootstrap it, and prove the owner's browser session reaches protected Dashboard resources; that evidence must not be described as production first-user provisioning. ## Consequences - An anonymous Dashboard visit reaches a real, accessible email/password login without exposing Better Auth's session token to browser application JavaScript. - `/api/v1/principal` is the only supported browser session read; token-bearing native session and password mutation routes cannot become an accidental parallel contract. - Reload uses the existing cookie and principal endpoint; sign-in and sign-out do not require a client router or full-page navigation. - Authentication transitions tear down the selected module through the generic loader's existing lifecycle, including its realtime cleanup. - A module remains ignorant of Ekklesia's credentials, auth endpoints, cookies, and response-status policy. - An authorization denial does not automatically become logout, while an expired session can still return to login without a reload. - The host-only cookie preserves the narrow existing trust boundary and leaves deployment topology as explicit future work. ## Rejected alternatives - **Put login in a module or add auth methods to `ModuleUiHostContext`.** Authentication is platform shell policy and would make portable module UI depend on its Ekklesia host. - **Let the browser consume Better Auth's native sign-in success JSON.** Its token and user object duplicate the cookie authority and unnecessarily expose identity/session material to same-realm JavaScript. - **Leave native session endpoints mounted because this UI does not call them.** Same-realm code can call an available route directly; unused token-bearing inspection/mutation endpoints would bypass the principal contract, and `change-password` can issue a raw replacement token. - **Trust the sign-in response as the current user.** `/api/v1/principal` is the narrow Ekklesia identity contract and remains the only authority for host state. - **Treat every module `401` or `403` as session expiry.** A valid authenticated session may receive either status from module-owned behavior; automatic logout would let an untrusted response erase host authentication without consulting the principal authority. - **Accept a callback or return URL for convenience.** The Dashboard has no client router in this pass, so such an input creates redirect risk without a product need. - **Set a parent-domain cookie.** It broadens which hosts receive the credential and prejudges a deployment topology that does not exist. - **Add signup or password-reset links that point to incomplete flows.** Production enrollment and email delivery are not implemented. ## Deferred - invitation or operator-driven production enrollment, verified email, password recovery, authenticated account/password management, and production first-user creation; - shared/durable authentication throttling and proof against the deployed reverse proxy; - a production Dashboard origin, built-asset hosting, CSP, DNS, TLS, and proxy configuration; - a client router and any validated internal post-login return-location contract; - device registration, pairing, unattended credentials, and public-display policy from ADR 0033; - public Website authentication and routing; - standalone-host authentication UX; - every Phase 7B pass 2 product capability.