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