feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s

This commit is contained in:
ekklesia
2026-08-23 22:01:26 -05:00
parent 91aa9c2930
commit a8c7c50b4d
26 changed files with 2702 additions and 194 deletions
@@ -0,0 +1,257 @@
# 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.