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
@@ -310,6 +310,11 @@ not exist yet.
## Deferred
> **Cross-reference — see
> [ADR 0034](0034-host-owned-browser-authentication.md).** The host-owned Dashboard sign-in and
> sign-out flow deferred below has now landed. This section remains the historical scope boundary
> of ADR 0026; ADR 0034 is authoritative for browser authentication and session transitions.
Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering,
icons, a home screen, module categories, configurable or administrator-owned navigation, and the
display metadata each would need in the inventory; a design system, theme tokens, or component
@@ -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.
+1
View File
@@ -42,3 +42,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
+41 -8
View File
@@ -155,7 +155,9 @@ elevation — but is disabled by default as of Phase 5D pass 1, below. See
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
contracts. Email/password sign-in, principal resolution, and sign-out are operational. Public
registration is available only when explicitly enabled for development/test; production rejects it
until verified enrollment and durable shared abuse controls exist.
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
@@ -226,8 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
independent.
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
dependencies, consumed by module authors and hosts alike. Its host context is four members —
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
dependencies, consumed by module authors and hosts alike. Its host context is five members —
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
`api.request(path)` with a path relative to the module's own API root, never by constructing
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
Client-side permission hints are presentation only; server-side enforcement remains the sole
@@ -244,8 +246,9 @@ option in one `tooling` package so module authors write no build configuration.
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
stylesheet loading contract, the standalone host, and every feature module remain future work.
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
contract, standalone host, and every real feature module remain future work.
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
@@ -338,9 +341,9 @@ permission the inventory does; organizing requires the new platform permission
shell renders that arrangement and computes none of it.
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment
for the built shell, sign-in UI, general asset publication, and the standalone host remain future
work.
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
pass 1 later supplies the host-owned sign-in flow described below.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
@@ -504,3 +507,33 @@ whether routes exist, and installed-manifest changes remain restart-based. Realt
object URLs are unchanged and flow through the same portable contracts. Named display variants,
targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
Announcements migration, and Companion integration remain later work.
Phase 7B pass 1 closes the Dashboard's browser authentication loop
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). The surface, not a module, owns
the email/password form, sign-out control, pending/failure states, and session transitions. Bootstrap
stays principal-first: anonymous renders login, authenticated proceeds to protected Dashboard
navigation, and an identity infrastructure failure remains a retryable failure rather than a false
anonymous result. Successful sign-in is followed by a fresh `/api/v1/principal` resolution before
any module mounts; sign-out is followed by the same resolution. Leaving authenticated state removes
the Dashboard subtree so the generic loader performs the selected module's normal cleanup, including
its realtime subscription teardown.
The browser posts to Better Auth's existing `/api/auth/sign-in/email` and `/api/auth/sign-out`
routes with credentials included. `apps/api` normalizes any body-bearing successful `2xx` email
sign-in response to fixed `{"ok":true}` while preserving its status and the HttpOnly `Set-Cookie`;
the redundant Better Auth token and user object never reach browser application JavaScript. The
token-bearing native `/api/auth/get-session`, `/api/auth/list-sessions`,
`/api/auth/update-session`, and `/api/auth/change-password` paths are unavailable behind a sanitized
`404`; `/api/v1/principal` is the browser's only supported session read, and password/account
management remains out of scope. The cookie remains host-only and exact trusted origins,
credentialed CORS, and origin/CSRF checks remain unchanged. Login stays at the current Dashboard URL
and accepts no callback or return target, so this pass introduces no redirect sink.
The five-member module UI context is unchanged; modules receive no auth method, credential, cookie,
or session token. Platform-owned bootstrap `401` maps directly to unauthenticated, but module-owned
responses are not host session authority: module `401` and `403` both trigger a deduplicated
principal revalidation, and only an anonymous result resets authentication and unmounts the module.
An authenticated result preserves the response, session, and mounted UI. Display device identity
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
and the missing production first-user creation path are also outside this pass.
@@ -74,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- Dashboard login, logout, and session-transition UX are surface-owned
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). They use the API over HTTP and
must not add credentials, cookies, tokens, or auth methods to `ModuleUiHostContext`; modules keep
receiving only the host-neutral principal and permission hints already in the UI SDK.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
+102 -32
View File
@@ -1,8 +1,9 @@
# API
Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see
Phase 4B added `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
one Ekklesia-owned route to resolve the current principal. Later phases added authorization, module
transport, the Dashboard shell, and its host-owned browser login/logout flow; Website and Mobile
authentication still do not exist. See
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
A follow-up corrective slice hardened this transport (sanitized Better Auth logging,
@@ -24,14 +25,14 @@ keyset listing — see
## What this is, and is not
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
principal, and sign out; public account creation is also possible once explicitly enabled (see
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an
email address, recover a forgotten password, use any provider besides email/password, reach a
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
It is possible to start the API, sign in, resolve an Ekklesia principal, sign out, and drive the
Dashboard's authenticated shell. Public account creation is possible only when explicitly enabled
for development/test (see [Public registration policy](#public-registration-policy)). It is **not**
possible to verify an email address, recover a forgotten password, use another provider, or create
the first production identity through a supported enrollment flow. **Public registration is not
ready for a real public launch**: there is no email delivery service, so an unverified email is
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
abuse control exists yet even with signup enabled.
abuse control exists even when development signup is enabled.
## Starting the API
@@ -72,7 +73,9 @@ No real values are shown here — configure per environment and never commit the
| Route | Owner | Notes |
| --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `POST /api/auth/sign-in/email` | Better Auth through Ekklesia's browser-safe response boundary | Uses Better Auth's native email/password request, status, failure, and `Set-Cookie` semantics. Any body-bearing successful `2xx` is normalized to fixed `{"ok":true}` so Better Auth's redundant token/user JSON cannot reappear if its success status changes. The pinned release currently returns `200`. |
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
@@ -89,8 +92,9 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
| `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
Better Auth's handler is unreachable outside `/api/auth/*`. The four denied native
session/password paths inside that prefix are deliberately indistinguishable from an unsupported
route; every other unmatched path returns the same deterministic `404 {"code":"NOT_FOUND"}`.
### `GET /api/v1/principal` response shapes
@@ -121,12 +125,62 @@ dropped column, a revoked `SELECT` privilege, an unreachable database, and a que
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email,
name, image, session id or expiry, session token, account/provider details, or any role,
permission, membership, or volunteer data.
`/api/v1/principal` is the only supported browser session read. Better Auth's native
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` routes expose or mutate token-bearing native session/user shapes, so
Ekklesia denies them with the sanitized `404` above instead of allowing a richer parallel identity
contract. Password/account management is out of scope; the pinned change-password route can return
a new raw token when asked to revoke other sessions. The principal response never
contains email, name, image, session id or expiry, session token, account/provider details, or any
role, permission, membership, or volunteer data.
## Dashboard browser authentication
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
five-member module UI context gains no authentication method. Bootstrap always requests
`/api/v1/principal` first. Anonymous renders the host login, authenticated proceeds to protected
Dashboard navigation, and a principal `503` remains a retryable infrastructure failure rather than
a false signed-out state.
The login form posts only `{email, password}` to `/api/auth/sign-in/email` with
`credentials: "include"`. On any body-bearing successful `2xx`, the API preserves Better Auth's
status, `Set-Cookie`, and other headers but discards the native token/user response body and returns:
```json
{ "ok": true }
```
The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an
authenticated result permits the navigation request and module mount. Credential denial and
service failure use distinct fixed messages, but no Better Auth response body, exception, email,
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup
or password-reset link because neither is a supported production flow.
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session.
During that transition the Dashboard subtree is unmounted, which invokes the generic module
loader's ordinary cleanup and closes module-owned realtime subscriptions. If the mutation throws or
principal resolution fails, protected UI stays removed behind a safe failure state with actions to
retry sign-out or explicitly rebootstrap; the host neither claims logout succeeded nor silently
restores authentication.
A `401` from a platform-owned bootstrap request maps directly to the shell's unauthenticated state;
that status is part of the host's own fixed resource contract. A platform navigation `403` causes a
principal recheck so an authenticated access denial is not confused with the narrow expiry race
between principal and navigation.
Module responses have different semantics. A module may return either `401` or `403` without
invalidating the platform session, so both statuses trigger one deduplicated principal revalidation.
The module still receives its original response. Only an anonymous principal unmounts the active
module and changes the shell to login; an authenticated principal preserves the session, response,
and mounted module. A revalidation infrastructure failure becomes a sanitized host failure. Stale
responses from an already-unmounted module frame cannot overwrite a newer auth transition, and
network errors, malformed responses, and `5xx` responses never fabricate logout.
Login remains at the current Dashboard URL. The shell accepts and forwards no return, callback, or
redirect target and performs no post-login navigation, so external and protocol-relative values
have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
## Public registration policy
@@ -163,12 +217,13 @@ registered account is an **authentication identity** — proof that someone can
and password — not a church-membership record; it does not imply attendance or involvement, and
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard
access, no module-management access, no staff/volunteer-management access, and no
media/event/form/roster/people-management permission — Core has no authorization schema in this
slice to grant any of that, and no code path in this repository inspects how many accounts already
exist or treats an empty database as special. Baseline authenticated status is what will eventually
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
saved content, preferences) — none of those features are implemented in this PR.
management authority, no module-management access, no staff/volunteer-management access, and no
media/event/form/roster/people-management permission. A signed-in identity can satisfy permissions
whose registered baseline is deliberately `authenticated` — including the Dashboard's
`core.modules.ui.read` — but signup itself creates no explicit grant and no code path inspects how many
accounts already exist or treats an empty database as special. Baseline authenticated status may
also support future ownership-scoped personal features (watch history, continue watching, saved
content, preferences); none of those features are implemented here.
Explicit **platform-owner bootstrap** is now available as a local operator command — see
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
@@ -177,6 +232,12 @@ platform-owner authority; signup order is never part of that decision. Future
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
one.
That existing-identity requirement is an unresolved production bootstrap gap. Production cannot
enable public signup, and Ekklesia has no invitation or operator-owned user-creation command, so a
fresh production installation has no supported path to create the first identity that the owner
bootstrap command requires. Development/test fixtures that enable signup and then bootstrap their
created user prove authorization composition, not a production enrollment workflow.
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate:
@@ -227,7 +288,10 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above.
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
simplest production topology, while a deliberately split browser/API origin must use the exact
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
## Logging
@@ -315,7 +379,9 @@ always the resolved, server-owned address described above — never a caller-con
rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
a genuinely multi-instance deployment still needs a proven shared or edge limiter. The current
three-attempt/ten-second path rule is useful per-process throttling, not durable brute-force
protection across restarts or replicas.
## Request body limits
@@ -1295,13 +1361,15 @@ stopped first, through its own `stop` hook, then already-started modules in reve
under the same bounded per-hook deadline — before the failure propagates, and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
## A real signup → sign-in → principal → sign-out transcript
## A real development signup → browser-safe sign-in → principal → sign-out transcript
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
database, configured as **development** with public sign-up explicitly enabled — public sign-up is
disabled by default and cannot be enabled in production at all (see
[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and
generated ids below are sanitized/truncated examples, not real credentials.
[Public registration policy](#public-registration-policy)). Cookie values and generated ids below
are sanitized/truncated examples, not real credentials. The successful sign-in body is the exact
browser-visible normalization; Better Auth's native token/user JSON has already been discarded at
the API boundary.
```text
POST /api/auth/sign-up/email
@@ -1313,7 +1381,7 @@ POST /api/auth/sign-in/email
{"email":"[email protected]","password":"<redacted>"}
→ 200
Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure
{"redirect":false,"token":"<redacted>","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}}
{"ok":true}
GET /api/v1/principal
Cookie: better-auth.session_token=<redacted>
@@ -1338,5 +1406,7 @@ Cookie: better-auth.session_token=<redacted, now revoked>
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains before Surface work begins. This document
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
for what has landed across every phase and what remains. Surface work is underway; production
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
database-composition contracts as they evolve.
+7
View File
@@ -294,6 +294,13 @@ before the id is even parsed as a valid `EkklesiaUserId` — this command never
signup order or "the only user in the database"; the operator must name the exact, opaque id
twice.
**The command grants authority; it does not create credentials.** Production public signup is
prohibited, and the repository currently has no invitation flow or operator-owned user-creation
command. A fresh production installation therefore has no supported path to create the initial
identity this command requires. Development integration tests may enable signup, create a user,
bootstrap that existing id, and prove its Dashboard session, but that is test composition — not a
production first-user provisioning workflow. This gap must be closed before launch.
### Bootstrap is permanently single-use
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
+35 -13
View File
@@ -19,9 +19,11 @@ surface concern. Better Auth is an implementation detail contained entirely behi
Auth instance, session token, or raw database client.** This holds structurally today: modules
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes.
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet.
- **Surfaces authenticate through Core-exposed HTTP/API contracts**, never by querying Better
Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that transport. The Dashboard
now owns its browser login/logout UX and uses only `/api/auth/sign-in/email`,
`/api/auth/sign-out`, and the narrow `/api/v1/principal` contract; modules receive none of those
controls. Website and Mobile authentication do not exist yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed
@@ -168,7 +170,8 @@ address passes this validation, and a firewall, network policy, or reverse proxy
parsing — is what actually determines who can reach a deployment.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
ADR 0010. The Dashboard flow in ADR 0034 deliberately retains a host-only cookie: a future
deployment must not assume sibling subdomains share a session.
## The identity runtime (Core's Better Auth transport boundary)
@@ -184,6 +187,21 @@ exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's sessi
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md).
Core still returns Better Auth's normal `Response` to that consumer. At the browser-facing API
composition boundary, [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)
defines exactly two exceptions. First, any body-bearing successful `2xx` from
`POST /api/auth/sign-in/email` preserves Better Auth's status, headers, and HttpOnly `Set-Cookie`
but replaces its token/user JSON with fixed `{"ok":true}`. Second, the token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` receive a sanitized `404` without reaching Better Auth. The last path
is denied even though password/account management is not built, because the pinned implementation
can return a new raw session token when revoking other sessions.
Failures and every other supported auth endpoint retain their native semantics. The Dashboard
ignores even the normalized sign-in body as identity and re-resolves `/api/v1/principal`, the only
supported browser session read; no session token is exposed to browser application JavaScript or
added to a module context.
Construction also explicitly configures four things the pinned release would otherwise leave on an
environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
and [API](api.md#logging):
@@ -233,13 +251,17 @@ database, and a query timeout therefore all produce a sanitized
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## What is not operational yet
## What is and is not operational
No email verification, password recovery, invitation flow, email delivery, social or OAuth
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. Core's authorization foundation
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on.
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
policy, not a production capability: it is disabled by default, may be explicitly enabled only for
development/test, and production rejects it. No email verification, password recovery, invitation
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
an existing identity; it does not create one. Because production signup is prohibited and no
operator enrollment command exists, creating the initial production identity needed for owner
bootstrap remains an explicit operational gap.
+54 -3
View File
@@ -134,8 +134,8 @@ remain the sole authority for authorization regardless of what a browser loaded.
### The browser host mounts module UI; it grants nothing
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through
`GET /api/v1/module-ui` and mounts them with the generic loader
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through the resolved
`GET /api/v1/dashboard/navigation` view and mounts each embedded host-neutral descriptor with the generic loader
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
security-relevant.
@@ -158,6 +158,44 @@ shell owns the container, the loading state, and every error, so a failing modul
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
### Dashboard authentication stays outside module code
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md) makes the Dashboard
surface — not a module — the owner of login, logout, and session-transition UX. Principal resolution
still precedes every protected navigation request and module mount. The public module UI context
remains `moduleId`, scoped API, narrow principal, permission hints, and realtime; it receives no
credential, cookie, token, auth endpoint, or sign-out callback.
The browser posts email/password to Better Auth's existing `/api/auth/sign-in/email` endpoint with
credentials included. Better Auth establishes the HttpOnly cookie, but its native success JSON also
contains a redundant session token and user object. `apps/api` therefore normalizes any body-bearing
successful `2xx` on that exact route to fixed `{"ok":true}` while preserving the status and
`Set-Cookie`; browser application JavaScript never receives the token and must resolve
`/api/v1/principal` before authenticated rendering. The richer token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` are deliberately unavailable behind the same sanitized `404` as an
unsupported route. The last path is denied even though password/account management is out of scope:
the pinned endpoint may return a new raw session token when revoking other sessions. The form clears
the submitted password, suppresses duplicate attempts, uses fixed error messages, and does not log
credentials, raw responses, or caught exceptions.
Sign-out calls `/api/auth/sign-out` and then re-resolves the principal; hiding the shell alone is
never treated as invalidation. Leaving authenticated state removes the active module frame, so its
existing unmount closes API/realtime work without a module-specific auth event. A `401` from the
platform-owned bootstrap contract maps directly to unauthenticated; a platform navigation `403`
receives a principal recheck for the principal/navigation expiry race. Module responses are not host
session authority: either module `401` or `403` triggers one deduplicated principal revalidation,
while the module retains the original response. Only anonymous unmounts the module and shows login;
authenticated preserves the session and mounted module. A stale response from an already-unmounted
frame is ignored, and network/server failures do not fabricate logout.
The flow remains at the current Dashboard URL and accepts no callback/return target, so this pass
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
separate deployment and threat-model decision.
### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module
@@ -445,7 +483,9 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
work all proceed against the local development infrastructure while every item below is still
outstanding.
1. The reverse-proxy path serves the API successfully.
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
repository currently supplies neither deployment.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
@@ -458,6 +498,13 @@ outstanding.
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
verification Ekklesia already requires.
9. The deployed login/logout flow preserves the host-only HttpOnly cookie, returns fixed
`{"ok":true}` rather than a token on successful sign-in, keeps the four token-bearing native
session/password paths unavailable, and accepts no external/protocol-relative post-login
destination.
10. The installation has a supported, audited way to create its first production identity before
consuming the single-use platform-owner bootstrap. This repository does not provide that way
yet.
### Rate limiting is per-process today
@@ -471,6 +518,10 @@ If either changes — public signup is enabled, or a second instance is added
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
Its production sign-in/sign-up-prefixed rule is three attempts per ten seconds, and the
server-owned client address prevents a spoofed forwarding header from selecting a fresh bucket.
Those facts still do not make counters durable across restart or sufficient across replicas; the
public Dashboard login must be assessed with the actual edge/proxy controls before launch.
## Continuous integration boundary
+37 -2
View File
@@ -42,6 +42,33 @@ a module entrypoint, or starts a server. Real, working (non-throwing) module fix
production module-runtime startup/shutdown ordering and cleanup against a mocked pool/Better
Auth/server — see [Authorization](authorization.md#tests).
Phase 7B pass 1 adds browser-authentication evidence
([ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)). API tests pin the
two exact browser-boundary exceptions. Any body-bearing successful `2xx` from
`POST /api/auth/sign-in/email` preserves Better Auth's `Set-Cookie` and status but returns fixed
`{"ok":true}`, with no token/user body. `/api/auth/get-session`, `/api/auth/list-sessions`,
`/api/auth/update-session`, and `/api/auth/change-password` each receive the identical sanitized
`404` without invoking the identity handler; failures and every other supported auth route retain
their native semantics. The live browser transport additionally proves the host-only cookie and
that no token, session, user, or email appears in the sign-in body.
Shell protocol and React tests cover principal-first anonymous/authenticated/failure states; the
labelled email/password form and keyboard submission; autocomplete, pending state, and same-tick
duplicate suppression; password clearing; fixed credential-versus-service errors; no credential or
raw exception logging; fresh principal and navigation resolution after login; sign-out followed by
principal resolution; and retry without a full-page reload.
The same shell suites exercise session transitions under repeated timing: active module cleanup and
realtime teardown on sign-out/verified expiry; stale attempts after unmount; platform bootstrap
`401` mapping directly to unauthenticated; and deduplicated principal revalidation for both module
`401` and `403`. They prove an authenticated recheck preserves the module's original response,
session, and mounted UI; an anonymous recheck unmounts once and shows login; a revalidation failure
is a sanitized host failure; and a response from an already-unmounted frame cannot overwrite a newer
transition. Return/callback input is not part of the browser contract, and tests pin that external,
protocol-relative, backslash-prefixed, and scheme-bearing values cannot become navigation.
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
of the separate display bundle.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.
@@ -79,8 +106,9 @@ A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in
`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly
migrated disposable database and exercises it with real HTTP requests: public sign-up (with no
automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only
`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie
`HttpOnly`/`SameSite=Lax`/secure session cookie and fixed `{"ok":true}` body containing no token,
`/api/v1/principal` for both the authenticated and anonymous shapes, sign-out actually invalidating
the session, a malformed or missing cookie
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
producing identical principal shapes with no elevation and no owner/role/permission data at the
database level, no plaintext password ever stored, the exact 8–128 password-length boundary (7
@@ -90,6 +118,13 @@ outage during principal resolution producing a sanitized `503` rather than a fal
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown.
The browser-authentication chain in that live suite also creates a development identity, invokes
Core's real single-use platform-owner bootstrap for that existing id, signs in through the real HTTP
transport, presents the returned cookie to `/api/v1/principal`, and reaches protected Dashboard
navigation with the owner authority. It proves composition across identity, authorization, and the
Dashboard resource; because production signup is prohibited, it deliberately does **not** claim to
prove a production first-user enrollment path.
A dedicated group within that suite proves session-store readiness against the real database rather
than a mock, since that is the only place a broken store looks exactly like an anonymous request: a
request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous