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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user