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