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
+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.