feat: add browser auth shell
This commit is contained in:
+102
-32
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user