feat: add browser auth shell
This commit is contained in:
@@ -310,6 +310,11 @@ not exist yet.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see
|
||||
> [ADR 0034](0034-host-owned-browser-authentication.md).** The host-owned Dashboard sign-in and
|
||||
> sign-out flow deferred below has now landed. This section remains the historical scope boundary
|
||||
> of ADR 0026; ADR 0034 is authoritative for browser authentication and session transitions.
|
||||
|
||||
Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering,
|
||||
icons, a home screen, module categories, configurable or administrator-owned navigation, and the
|
||||
display metadata each would need in the inventory; a design system, theme tokens, or component
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# 0034: Dashboard browser authentication is host-owned and principal-first
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-23
|
||||
- Amends: [0011](0011-identity-http-transport-and-public-enrollment.md)
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia already had email/password authentication, an HttpOnly session cookie, a minimal
|
||||
`GET /api/v1/principal` contract, authorization middleware, and a Dashboard shell. The shell also
|
||||
already resolved the principal before requesting protected navigation. What it did not have was a
|
||||
browser path for an anonymous person to become authenticated or for an authenticated person to end
|
||||
their session. An anonymous bootstrap stopped at a message telling the viewer to sign in, while no
|
||||
surface drove the existing authentication endpoints.
|
||||
|
||||
Closing that gap affects more than a form. A module UI may have an active API request or realtime
|
||||
subscription when a session expires or a person signs out. Better Auth's successful email sign-in
|
||||
response also includes a session token and user object in JSON even though the browser session is
|
||||
already represented by an HttpOnly cookie. A browser flow must not expose or adopt that redundant
|
||||
token, turn every authorization denial into logout, create an open-redirect input, or move platform
|
||||
authentication into a feature module.
|
||||
|
||||
The display host adds a second important boundary. It is permission-gated today, but unattended
|
||||
device identity is explicitly deferred by ADR 0033. Adding Dashboard sign-in must not silently make
|
||||
the display application a device enrollment flow or add Dashboard chrome to its bundle.
|
||||
|
||||
## Decision
|
||||
|
||||
### The host owns authentication UX; modules retain the host-neutral context
|
||||
|
||||
`surfaces/shell` owns the Dashboard's sign-in form, pending and failure states, sign-out control,
|
||||
session-transition behavior, and credential submission. A module never renders the platform login,
|
||||
imports an authentication client, receives a session token, or chooses how the host authenticates.
|
||||
|
||||
The public module UI contract remains exactly the five-member, host-neutral
|
||||
`ModuleUiHostContext`: `moduleId`, `api`, `principal`, `permissions`, and `realtime`. No sign-in,
|
||||
sign-out, credential, cookie, token, or Ekklesia-specific session callback is added to it. The host
|
||||
may observe authorization responses from the API client it supplies, but that observation remains
|
||||
host-private and the module still receives the original response.
|
||||
|
||||
This keeps one module artifact runnable under Ekklesia and a future standalone host. A standalone
|
||||
application will own its authentication experience just as the Ekklesia Dashboard owns this one;
|
||||
the module will continue to see only the same narrow principal and permission hints.
|
||||
|
||||
### Dashboard bootstrap remains principal-first
|
||||
|
||||
The Dashboard resolves `GET /api/v1/principal` before requesting navigation or rendering any module
|
||||
UI:
|
||||
|
||||
```text
|
||||
loading
|
||||
-> anonymous -> host-owned login
|
||||
-> authenticated -> protected Dashboard navigation -> selected module
|
||||
-> unavailable/invalid -> sanitized failure with explicit retry
|
||||
```
|
||||
|
||||
An anonymous principal is an ordinary, successful identity result, not an application failure. A
|
||||
principal infrastructure failure is not anonymous and does not reveal a login form that would
|
||||
misrepresent the outage as a missing session. No protected Dashboard request is made and no module
|
||||
code is loaded until the authenticated branch is established.
|
||||
|
||||
After a successful sign-in mutation, the host does not trust an authentication response as a
|
||||
principal. It resolves `/api/v1/principal` again and only then loads Dashboard navigation. This
|
||||
changes the rendered state in place; a full page reload is not required.
|
||||
|
||||
### Email/password uses the native endpoint behind two narrow browser-safe exceptions
|
||||
|
||||
The browser submits JSON credentials with `credentials: "include"` to Better Auth's existing
|
||||
native endpoint:
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-in/email
|
||||
```
|
||||
|
||||
ADR 0011 made normal Better Auth responses pass through the Core identity runtime unchanged. That
|
||||
remains true inside Core. This decision introduces exactly two browser-facing exceptions at the
|
||||
`apps/api` transport boundary:
|
||||
|
||||
1. When the exact email sign-in endpoint returns any body-bearing successful `2xx`, `apps/api`
|
||||
preserves that status and the headers — including `Set-Cookie` — but replaces the body with the
|
||||
fixed response below.
|
||||
2. The token-bearing native session inspection/mutation paths `/api/auth/get-session`,
|
||||
`/api/auth/list-sessions`, `/api/auth/update-session`, and `/api/auth/change-password` are
|
||||
unavailable. The API returns the same sanitized `404 {"code":"NOT_FOUND"}` as an unsupported
|
||||
route without invoking Better Auth. Password/account management is outside this pass; the pinned
|
||||
change-password route can return a new raw token when it revokes other sessions, so it cannot be
|
||||
left as an unused bypass.
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Sign-in failures and every other supported Better Auth endpoint keep their existing response
|
||||
semantics. These exceptions are not a second authentication protocol: supported requests still
|
||||
reach Better Auth, Better Auth still creates and invalidates the session, and its HttpOnly cookie
|
||||
remains the only browser session authority. The normalization discards the redundant session token
|
||||
and user object before browser application JavaScript can read them. The denylist prevents a caller
|
||||
from bypassing the narrow Ekklesia identity contract to inspect or mutate native session objects.
|
||||
The supported browser identity read is exclusively `GET /api/v1/principal`; the shell treats
|
||||
`{ "ok": true }` only as permission to re-resolve it and does not persist, log, or derive identity
|
||||
from the body.
|
||||
|
||||
The form exposes only email and password. It is a semantic browser form with labelled inputs,
|
||||
`username` and `current-password` autocomplete hints, keyboard submission, one in-flight attempt,
|
||||
and a visible pending state. A completed attempt clears the password while retaining the email for
|
||||
correction. Credential rejection has one generic message; service and malformed-response failures
|
||||
have a separate fixed generic message. Raw response bodies, caught exceptions, credentials, and
|
||||
request URLs never reach the DOM or console.
|
||||
|
||||
### Sign-out invalidates first, then asks the principal authority
|
||||
|
||||
The host submits an empty JSON object with `credentials: "include"` to the existing endpoint:
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-out
|
||||
```
|
||||
|
||||
After the sign-out mutation succeeds, the host resolves `/api/v1/principal` instead of assuming
|
||||
that hiding the Dashboard equals logout. An anonymous result shows the login form. If the sign-out
|
||||
request throws or the subsequent principal resolution fails, protected UI stays removed and the
|
||||
host shows a sanitized failure with actions to retry sign-out or explicitly rebootstrap. It neither
|
||||
claims that the session ended nor automatically restores an authenticated state it has not
|
||||
revalidated.
|
||||
|
||||
While the transition is in progress, the authenticated Dashboard subtree is removed. The generic
|
||||
module loader therefore invokes the selected module's existing unmount cleanup, and the module's
|
||||
host-owned realtime subscriptions close through their ordinary cleanup path. No module-specific
|
||||
logout event or teardown API is introduced.
|
||||
|
||||
### Platform bootstrap and module responses use different status policies
|
||||
|
||||
A `401` from a platform-owned bootstrap request is an authentication challenge in the host's own
|
||||
fixed contract. The platform request helper maps it directly to `HOST_UNAUTHENTICATED`, and the
|
||||
principal-first Dashboard returns to login without treating it as an arbitrary module response. A
|
||||
platform navigation `403` remains an access denial and receives the existing principal recheck so
|
||||
the host can distinguish an authenticated denial from a session that expired between the principal
|
||||
and navigation requests.
|
||||
|
||||
A module owns its response semantics and may return `401` or `403` for reasons that do not invalidate
|
||||
the host session. Either status observed through the module API facade therefore triggers one
|
||||
deduplicated `/api/v1/principal` revalidation while the module still receives its original response:
|
||||
|
||||
- anonymous means the session ended, so the host unmounts the active module and shows login;
|
||||
- authenticated means the session and mounted module remain in place and the original module
|
||||
response remains that module's result;
|
||||
- an infrastructure failure becomes a sanitized retryable host failure, never a fabricated logout.
|
||||
|
||||
A burst of module denials shares the in-flight principal revalidation. A response retained by a
|
||||
module frame that has already unmounted cannot start or overwrite a newer auth transition. A network
|
||||
error, malformed response, `404`, or `5xx` never clears authentication merely because it occurred.
|
||||
Realtime retains ADR 0032's native reconnect and five-minute server reauthorization; when a verified
|
||||
anonymous principal moves the host out of the authenticated state, normal module unmount closes its
|
||||
EventSource.
|
||||
|
||||
### There is no return URL or redirect sink in this pass
|
||||
|
||||
Login renders at the current Dashboard URL and successful authentication replaces that state in
|
||||
place. The host accepts no `return`, `returnTo`, `callbackURL`, or equivalent request/query value,
|
||||
passes no callback URL to Better Auth, and performs no script-driven navigation. External,
|
||||
protocol-relative, backslash-prefixed, or scheme-bearing input therefore has no path to become a
|
||||
redirect target.
|
||||
|
||||
A future client router may define an internal return-location contract, but it must be a separately
|
||||
validated, same-origin route decision. This pass does not create one speculatively.
|
||||
|
||||
### Cookie and origin policy does not broaden for the Dashboard
|
||||
|
||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||
Secure when the canonical identity base URL uses HTTPS, Path `/`, and no `Domain` attribute. It is
|
||||
host-only. Ekklesia does not broaden it to a parent domain merely so sibling subdomains can share a
|
||||
session.
|
||||
|
||||
The simplest production shape is a Dashboard whose `/api` traffic is reverse-proxied on the same
|
||||
origin. A deliberately split browser/API origin remains possible only through the exact canonical
|
||||
trusted-origin allowlist and credentialed CORS already defined by ADR 0011, ADR 0012, and ADR 0018.
|
||||
Wildcards remain invalid. Better Auth's origin and CSRF checks remain explicitly enabled in every
|
||||
environment; the browser supplies its real `Origin` naturally rather than manufacturing or
|
||||
weakening it.
|
||||
|
||||
The repository still provides no production Dashboard hostname, DNS, TLS, reverse-proxy rule,
|
||||
Content-Security-Policy, or static-asset deployment. Host-only cookies mean any eventual choice to
|
||||
place the API and Dashboard on different sibling subdomains must be designed and tested rather than
|
||||
assumed to share a session.
|
||||
|
||||
### Enrollment, recovery, throttling, and first ownership remain explicit gaps
|
||||
|
||||
The login page is for an existing account. It contains no create-account link because public signup
|
||||
is disabled by default and prohibited in production by ADR 0017. It contains no forgot-password or
|
||||
verification flow because Ekklesia has no email sender, recovery delivery, or verified-email
|
||||
contract. Their absence is visible product scope, not a hidden link to a non-operational endpoint.
|
||||
|
||||
Better Auth's sign-in limiter remains enabled with its strict per-path production rule of three
|
||||
attempts per ten seconds. Its store is in-memory and per API process: counters do not span multiple
|
||||
instances and do not survive restart. The server-owned client-address boundary prevents a caller
|
||||
from choosing a new bucket with a spoofed forwarding header, but it does not turn this into a shared
|
||||
or durable brute-force defense. A multi-instance or publicly launched deployment still needs a
|
||||
proven shared or edge limiter.
|
||||
|
||||
The platform-owner bootstrap command still accepts only an **existing** Ekklesia user ID. Because
|
||||
production public signup is prohibited and no invitation or operator-owned user-creation command
|
||||
exists, this repository does not yet provide a supported way to create the first production
|
||||
identity that bootstrap needs. Tests may create a development identity, bootstrap it, and prove the
|
||||
owner's browser session reaches protected Dashboard resources; that evidence must not be described
|
||||
as production first-user provisioning.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An anonymous Dashboard visit reaches a real, accessible email/password login without exposing
|
||||
Better Auth's session token to browser application JavaScript.
|
||||
- `/api/v1/principal` is the only supported browser session read; token-bearing native session and
|
||||
password mutation routes cannot become an accidental parallel contract.
|
||||
- Reload uses the existing cookie and principal endpoint; sign-in and sign-out do not require a
|
||||
client router or full-page navigation.
|
||||
- Authentication transitions tear down the selected module through the generic loader's existing
|
||||
lifecycle, including its realtime cleanup.
|
||||
- A module remains ignorant of Ekklesia's credentials, auth endpoints, cookies, and response-status
|
||||
policy.
|
||||
- An authorization denial does not automatically become logout, while an expired session can still
|
||||
return to login without a reload.
|
||||
- The host-only cookie preserves the narrow existing trust boundary and leaves deployment topology
|
||||
as explicit future work.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Put login in a module or add auth methods to `ModuleUiHostContext`.** Authentication is
|
||||
platform shell policy and would make portable module UI depend on its Ekklesia host.
|
||||
- **Let the browser consume Better Auth's native sign-in success JSON.** Its token and user object
|
||||
duplicate the cookie authority and unnecessarily expose identity/session material to same-realm
|
||||
JavaScript.
|
||||
- **Leave native session endpoints mounted because this UI does not call them.** Same-realm code can
|
||||
call an available route directly; unused token-bearing inspection/mutation endpoints would bypass
|
||||
the principal contract, and `change-password` can issue a raw replacement token.
|
||||
- **Trust the sign-in response as the current user.** `/api/v1/principal` is the narrow Ekklesia
|
||||
identity contract and remains the only authority for host state.
|
||||
- **Treat every module `401` or `403` as session expiry.** A valid authenticated session may receive
|
||||
either status from module-owned behavior; automatic logout would let an untrusted response erase
|
||||
host authentication without consulting the principal authority.
|
||||
- **Accept a callback or return URL for convenience.** The Dashboard has no client router in this
|
||||
pass, so such an input creates redirect risk without a product need.
|
||||
- **Set a parent-domain cookie.** It broadens which hosts receive the credential and prejudges a
|
||||
deployment topology that does not exist.
|
||||
- **Add signup or password-reset links that point to incomplete flows.** Production enrollment and
|
||||
email delivery are not implemented.
|
||||
|
||||
## Deferred
|
||||
|
||||
- invitation or operator-driven production enrollment, verified email, password recovery,
|
||||
authenticated account/password management, and production first-user creation;
|
||||
- shared/durable authentication throttling and proof against the deployed reverse proxy;
|
||||
- a production Dashboard origin, built-asset hosting, CSP, DNS, TLS, and proxy configuration;
|
||||
- a client router and any validated internal post-login return-location contract;
|
||||
- device registration, pairing, unattended credentials, and public-display policy from ADR 0033;
|
||||
- public Website authentication and routing;
|
||||
- standalone-host authentication UX;
|
||||
- every Phase 7B pass 2 product capability.
|
||||
@@ -42,3 +42,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
|
||||
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
|
||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||
|
||||
@@ -155,7 +155,9 @@ elevation — but is disabled by default as of Phase 5D pass 1, below. See
|
||||
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
|
||||
[the API guide](../development/api.md), and
|
||||
[the identity development guide](../development/identity.md) for the underlying schema and
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
|
||||
contracts. Email/password sign-in, principal resolution, and sign-out are operational. Public
|
||||
registration is available only when explicitly enabled for development/test; production rejects it
|
||||
until verified enrollment and durable shared abuse controls exist.
|
||||
|
||||
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
|
||||
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
|
||||
@@ -226,8 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
|
||||
independent.
|
||||
|
||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is four members —
|
||||
`moduleId`, `api`, `principal`, `permissions`. Module UI reaches its own backend through
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is five members —
|
||||
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
|
||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||
Client-side permission hints are presentation only; server-side enforcement remains the sole
|
||||
@@ -244,8 +246,9 @@ option in one `tooling` package so module authors write no build configuration.
|
||||
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
||||
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
||||
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||
only validates the artifact a module shipped. The Dashboard shell, a UI runtime, a design system, a
|
||||
stylesheet loading contract, the standalone host, and every feature module remain future work.
|
||||
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
|
||||
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
|
||||
contract, standalone host, and every real feature module remain future work.
|
||||
|
||||
**Phase 6B pass 3 makes the browser boundary real and adds the generic host loader.**
|
||||
[ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md) removes the UI SDK's last
|
||||
@@ -338,9 +341,9 @@ permission the inventory does; organizing requires the new platform permission
|
||||
shell renders that arrangement and computes none of it.
|
||||
|
||||
**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no
|
||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment
|
||||
for the built shell, sign-in UI, general asset publication, and the standalone host remain future
|
||||
work.
|
||||
Dashboard Home or widgets, no design system, and no client routing. A production CSP and deployment
|
||||
for the built shell, general asset publication, and the standalone host remain future work. Phase 7B
|
||||
pass 1 later supplies the host-owned sign-in flow described below.
|
||||
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
|
||||
are all unchanged.
|
||||
|
||||
@@ -504,3 +507,33 @@ whether routes exist, and installed-manifest changes remain restart-based. Realt
|
||||
object URLs are unchanged and flow through the same portable contracts. Named display variants,
|
||||
targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
|
||||
Announcements migration, and Companion integration remain later work.
|
||||
|
||||
Phase 7B pass 1 closes the Dashboard's browser authentication loop
|
||||
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). The surface, not a module, owns
|
||||
the email/password form, sign-out control, pending/failure states, and session transitions. Bootstrap
|
||||
stays principal-first: anonymous renders login, authenticated proceeds to protected Dashboard
|
||||
navigation, and an identity infrastructure failure remains a retryable failure rather than a false
|
||||
anonymous result. Successful sign-in is followed by a fresh `/api/v1/principal` resolution before
|
||||
any module mounts; sign-out is followed by the same resolution. Leaving authenticated state removes
|
||||
the Dashboard subtree so the generic loader performs the selected module's normal cleanup, including
|
||||
its realtime subscription teardown.
|
||||
|
||||
The browser posts to Better Auth's existing `/api/auth/sign-in/email` and `/api/auth/sign-out`
|
||||
routes with credentials included. `apps/api` normalizes any body-bearing successful `2xx` email
|
||||
sign-in response to fixed `{"ok":true}` while preserving its status and the HttpOnly `Set-Cookie`;
|
||||
the redundant Better Auth token and user object never reach browser application JavaScript. The
|
||||
token-bearing native `/api/auth/get-session`, `/api/auth/list-sessions`,
|
||||
`/api/auth/update-session`, and `/api/auth/change-password` paths are unavailable behind a sanitized
|
||||
`404`; `/api/v1/principal` is the browser's only supported session read, and password/account
|
||||
management remains out of scope. The cookie remains host-only and exact trusted origins,
|
||||
credentialed CORS, and origin/CSRF checks remain unchanged. Login stays at the current Dashboard URL
|
||||
and accepts no callback or return target, so this pass introduces no redirect sink.
|
||||
|
||||
The five-member module UI context is unchanged; modules receive no auth method, credential, cookie,
|
||||
or session token. Platform-owned bootstrap `401` maps directly to unauthenticated, but module-owned
|
||||
responses are not host session authority: module `401` and `403` both trigger a deduplicated
|
||||
principal revalidation, and only an anonymous result resets authentication and unmounts the module.
|
||||
An authenticated result preserves the response, session, and mounted UI. Display device identity
|
||||
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
|
||||
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
|
||||
and the missing production first-user creation path are also outside this pass.
|
||||
|
||||
@@ -74,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
|
||||
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
|
||||
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
|
||||
- Dashboard login, logout, and session-transition UX are surface-owned
|
||||
([ADR 0034](decisions/0034-host-owned-browser-authentication.md)). They use the API over HTTP and
|
||||
must not add credentials, cookies, tokens, or auth methods to `ModuleUiHostContext`; modules keep
|
||||
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
||||
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
||||
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
||||
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||
|
||||
+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