CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1644 lines
109 KiB
Markdown
1644 lines
109 KiB
Markdown
# API
|
||
|
||
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,
|
||
production-equivalent origin/CSRF protection under the test runner, bounded identity database
|
||
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
|
||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||
Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a
|
||
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
|
||
default, an explicit request-body-size policy for every public route (including a module route's
|
||
own declared contract), and finite Node header/request timeouts — see
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). Pass 3 made the
|
||
explicit-grant API's own request contracts exact: canonical UUID grant ids, one canonical
|
||
future-only expiration format, unscoped-only creation, a `409` duplicate conflict, and bounded
|
||
keyset listing — see
|
||
[Authorization](authorization.md#explicit-grant-http-api) and
|
||
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
|
||
|
||
## What this is, and 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 even when development signup is enabled.
|
||
|
||
## Starting the API
|
||
|
||
```bash
|
||
pnpm migrations:apply # the database must already be migrated — the API never does this itself
|
||
pnpm api:start
|
||
```
|
||
|
||
`pnpm api:start` follows the repository's build-first convention (`turbo run build`) so it works
|
||
from a clean checkout, then runs the compiled `apps/api/dist/server.js`. It reads configuration
|
||
from the environment (optionally via an uncommitted `.env`, loaded with
|
||
`--env-file-if-exists=.env`) and fails with a concise, sanitized error and a nonzero exit code if
|
||
required configuration is missing or invalid — it never applies migrations, creates the identity
|
||
tables, or repairs schema, and it fails safely (without mutating the database) if the required
|
||
schema is absent.
|
||
|
||
### Required configuration
|
||
|
||
No real values are shown here — configure per environment and never commit them:
|
||
|
||
| Variable | Requirement |
|
||
| --- | --- |
|
||
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. It must **not** carry a TLS parameter (`sslmode`, `ssl`, `sslrootcert`, …) — see [Database transport (TLS)](#database-transport-tls). |
|
||
| `EKKLESIA_DATABASE_TLS_MODE` | Optional. Exactly `"verify-full"` or `"disable"`. Defaults to `verify-full` in production and `disable` in development/test. Production refuses `disable`. |
|
||
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
||
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||
| `EKKLESIA_MODULES_DIR` | Optional. The installed-modules directory, as an absolute path ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). Unset means the checkout's own `modules/`, where a missing directory is simply zero modules. Set, the directory must exist: a relative, blank, or unreadable value, or a missing directory, stops startup before any database connection with a sanitized `MODULES_DIRECTORY_INVALID` or `MODULES_DIRECTORY_UNREADABLE` that never contains the value. The production image sets `/var/lib/ekklesia/modules`. Every operator command and Doctor read the same value. |
|
||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
|
||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
|
||
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
|
||
|
||
## Routes
|
||
|
||
| Route | Owner | Notes |
|
||
| --- | --- | --- |
|
||
| `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 /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||
| `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). |
|
||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
|
||
| `GET /display/<module-id>` | Ekklesia | Stable, full-viewport display page with no Dashboard chrome. Requires the module-owned permission declared beside `entrypoints.display`. See [Module display host](#module-display-host). |
|
||
| `GET /api/v1/module-displays/<module-id>` | Ekklesia | Protected descriptor for one display page: exact artifact, module API/realtime roots, and presentation-only permission hints. Requires that display's module-owned permission. See [Module display host](#module-display-host). |
|
||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||
| `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/*`. 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
|
||
|
||
Anonymous (no session, or an expired/revoked/malformed session):
|
||
|
||
```json
|
||
{ "kind": "anonymous" }
|
||
```
|
||
|
||
Authenticated:
|
||
|
||
```json
|
||
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
|
||
```
|
||
|
||
Infrastructure failure (the identity session store was not usable while resolving the principal —
|
||
never silently reported as anonymous):
|
||
|
||
```json
|
||
{ "code": "PRINCIPAL_RESOLUTION_FAILED" }
|
||
```
|
||
returned with HTTP `503`.
|
||
|
||
A request carrying no cookies, or only unrelated cookies, never reaches that path and never performs
|
||
identity database work at all: only a request presenting the *configured session cookie* that Better
|
||
Auth resolved to `null` is ambiguous enough to check. When it is checked, a missing session table, a
|
||
dropped column, a revoked `SELECT` privilege, an unreachable database, and a query timeout all
|
||
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).
|
||
|
||
`/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 host
|
||
|
||
`apps/api` serves the built Dashboard itself
|
||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
|
||
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
|
||
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||
needed. In the split topology
|
||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||
exactly like the display host:
|
||
|
||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||
exact URLs of two built files, `dist/browser/index.html` and
|
||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||
strong `ETag` for each.
|
||
- **The API's origin is written in at startup.** The built page carries one empty
|
||
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
|
||
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
|
||
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
|
||
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
|
||
is built into the bundle.
|
||
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
|
||
without exactly one empty API-origin element, stops `startApi()` with a fixed
|
||
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||
`pnpm api:start` already runs the full build.
|
||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||
router, and emailed links land on `/` with a query (see
|
||
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||
implicit `HEAD`) are also the ordinary `404`.
|
||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||
a `404` for it.
|
||
|
||
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||
`/api/v1/principal` says otherwise.
|
||
|
||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||
| --- | --- | --- |
|
||
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||
| `Cache-Control` | `no-cache` | `no-cache` |
|
||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||
| `X-Frame-Options` | `DENY` | — |
|
||
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||
| `Referrer-Policy` | `no-referrer` | — |
|
||
|
||
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
|
||
|
||
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
|
||
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
|
||
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
|
||
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
|
||
strips that token from the address bar before it renders anything.
|
||
|
||
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||
that path absolutely, so the page never depends on the URL that served it. For a split-origin
|
||
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
|
||
its copy of the page exactly as the API does in production. A production build ignores it.
|
||
|
||
## 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 offers **no signup** control,
|
||
because public registration is not a supported production flow. It does offer **Forgot password?**,
|
||
which is now a real backend flow (ADR 0038).
|
||
|
||
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).
|
||
|
||
### The four anonymous screens
|
||
|
||
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
|
||
forgot-password, verification (notice or result), and choose-a-new-password
|
||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). None of
|
||
them is authenticated, and none can become authenticated — `/api/v1/principal` still decides.
|
||
|
||
An emailed link returns to the Dashboard root carrying `?ekklesia_auth=verify-email` or
|
||
`?ekklesia_auth=reset-password`. Query parameters rather than paths, deliberately: the shell is a
|
||
static bundle, and a deep path would oblige every deployment's static host to rewrite unknown paths
|
||
to `index.html` before an emailed link worked at all.
|
||
|
||
The entry point parses that query once, with a pure function that recognizes only those two values
|
||
and **names no destination of any kind**, then removes it from the address bar with
|
||
`history.replaceState` — so a reset token never enters browser history, a shareable URL, or a
|
||
`Referer`. The token lives in React state for the life of one form and is never written to
|
||
`localStorage`, `sessionStorage`, or a cookie.
|
||
|
||
A correct password on an unverified address is reported as its own state (`403 EMAIL_NOT_VERIFIED`,
|
||
no cookie) so the shell can offer a resend rather than claim the password was wrong. It is never
|
||
confused with an authenticated principal.
|
||
|
||
## Public registration policy
|
||
|
||
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||
than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`,
|
||
`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp`
|
||
flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the
|
||
pinned release's own mechanism already has every required property: every `POST
|
||
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
|
||
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
|
||
looks up the email, so a disabled deployment never reveals whether an address is already
|
||
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate.
|
||
|
||
**Production cannot enable public signup at all right now.** A production configuration that sets
|
||
the flag to `true` is rejected outright with the stable
|
||
`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` configuration error, and `pnpm doctor` reports that state
|
||
as a blocking `NOT READY` failure rather than a warning. This is not a preference: the durable
|
||
signup-abuse prerequisites a public registration endpoint needs — verified email or an invitation
|
||
requirement, and durable shared/edge throttling rather than this release's per-process in-memory
|
||
limiter — **do not exist in this repository yet**, so there is no production configuration in which
|
||
the flag could be honored safely. The rejection happens in `createIdentityConfig`, which
|
||
`apps/api`'s executable entry point calls *before* `startApi`, so such a deployment never opens a
|
||
PostgreSQL pool, constructs Better Auth, starts the module runtime, or binds a listener.
|
||
|
||
Development and test composition may still enable it explicitly, through the same typed path
|
||
(`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` with `NODE_ENV=development`, or
|
||
`{ environment: "development" }` programmatically) — that is how this repository's own integration
|
||
suite creates accounts over HTTP. When the prerequisites above are actually implemented and proven,
|
||
`validatePublicSignupEnabled` (`core/src/identity/config.ts`) is the single place that changes. See
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||
|
||
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
|
||
registered account is an **authentication identity** — proof that someone can present this email
|
||
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
|
||
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
|
||
platform-owner authority; signup order is never part of that decision. Future
|
||
**relationship-derived access** (for example, a person added to a volunteer roster automatically
|
||
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:
|
||
per the pinned Better Auth release, `autoSignIn: false` activates its built-in email-enumeration
|
||
protection, so a duplicate sign-up returns the same `200` shape (with a fresh, unrelated random id)
|
||
whether or not the email was already registered, rather than revealing which. No custom
|
||
cryptography replaces Better Auth's own password hashing. Password length is pinned explicitly to
|
||
8–128 characters (`minPasswordLength`/`maxPasswordLength` in `core/src/identity/runtime.ts`) rather
|
||
than left on Better Auth's fallback default — the pinned release's default happens to be the same
|
||
8–128 range, but an implicit default can change silently on a future Better Auth upgrade, while an
|
||
explicit value cannot. `apps/api/tests/api-http.integration.ts` proves the exact boundary against a
|
||
real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
|
||
|
||
## Origin, CORS, and cookies
|
||
|
||
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
|
||
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
|
||
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
|
||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
|
||
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||
be processed — CORS is a browser-enforced, response-header mechanism, not a server-side gate. A
|
||
request with no `Origin` header at all (native or server-to-server clients) is unaffected by CORS.
|
||
Note this is narrower than it sounds for `/api/auth/sign-in/email` and `/api/auth/sign-up/email`
|
||
specifically: any client built on the Fetch API — every browser, and also Node's own `fetch()` —
|
||
automatically sends a `Sec-Fetch-Mode: cors` header on every request, and Better Auth's first-login
|
||
CSRF protection treats that as "this came from a Fetch client" and requires a trusted `Origin` (or
|
||
`Referer`) on those two routes regardless of whether a cookie is present. A genuinely non-Fetch
|
||
caller (curl, or most backend HTTP client libraries, which send none of the `Sec-Fetch-*` headers)
|
||
is unaffected and needs no `Origin` header at all — see the dedicated raw-socket test in
|
||
`apps/api/tests/api-http.integration.ts` for the exact distinction proven against a real server.
|
||
|
||
Better Auth's own CSRF/origin protections are explicitly kept on:
|
||
`core/src/identity/runtime.ts` sets `advanced: { disableCSRFCheck: false, disableOriginCheck:
|
||
false }`. This is deliberately explicit rather than left unset — the pinned release's own default
|
||
is environment-sensitive (`skipOriginCheck` falls back to `true`, silently disabling both origin
|
||
and CSRF checking, whenever `isTest()` is true, which Vitest's default `NODE_ENV=test` triggers),
|
||
so an unset config would mean origin/CSRF protection is silently off under the test runner and on
|
||
in production. Setting both flags explicitly removes that environment-sensitive branch entirely,
|
||
so `pnpm test:migrations:integration` now automatically proves the exact production behavior: any
|
||
non-`GET`/`HEAD`/`OPTIONS` request carrying a session cookie must also carry a trusted `Origin` (or
|
||
`Referer`), or Better Auth rejects it with `403` (and the presented session remains valid
|
||
afterward); a no-cookie first authentication attempt from an untrusted origin is rejected the same
|
||
way; a trusted origin is accepted in both cases; and a denied origin never receives a permissive
|
||
CORS header. See [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
|
||
for the root-cause investigation and the full account of what changed.
|
||
|
||
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
|
||
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. The Dashboard does not broaden this policy. In the split topology
|
||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
|
||
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
|
||
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
|
||
is therefore a requirement of that topology. The reference deployment provides both topologies
|
||
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
|
||
|
||
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
|
||
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
|
||
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
|
||
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
|
||
headers. That keeps one module artifact working in either topology.
|
||
|
||
## Logging
|
||
|
||
Two independent, complementary layers, both configured explicitly in `core/src/identity/runtime.ts`
|
||
rather than left on any environment-sensitive default:
|
||
|
||
1. **`logger`.** Better Auth's own default (`level: "warn"`, plain `console.*` output) is replaced.
|
||
The pinned release's log call sites are not a stable, fully-audited contract — several pass a
|
||
raw internal exception, and at least one (the origin-check middleware) interpolates a
|
||
user-controlled `Origin` header directly into the message text. Rather than sanitize each call
|
||
site's message and arguments individually, Core's logger configuration drops the message and
|
||
every argument entirely and emits only a fixed, generic string
|
||
(`"[identity] Better Auth reported an error."`) via `console.error`, and only for error-level
|
||
events — nothing is ever emitted for warn/info/debug.
|
||
2. **`onAPIError: { throw: true }`.** Discovered while testing (1) above: the pinned release's own
|
||
HTTP transport dependency, `better-call`'s router, has an *unconditional*
|
||
`console.error("# SERVER_ERROR: ", error)` fallback for any thrown value it does not recognize
|
||
as one of Better Auth's own `APIError`s — this fires before Better Auth's `logger` option is
|
||
ever consulted, so no `logger` configuration can reach it. `onAPIError.throw: true` closes that
|
||
gap: Better Auth's own deliberate `APIError`s (wrong password, invalid origin, disabled sign-up,
|
||
password out of bounds, ...) still resolve to their normal, correct HTTP response completely
|
||
unaffected, but a genuinely unexpected failure (for example a database outage mid-request) now
|
||
propagates out of `auth.handler()` instead of being caught and printed raw internally by
|
||
`better-call`, reaching `handleAuthRequest`'s own try/catch and becoming a sanitized
|
||
`IdentityRuntimeError` like any other unexpected failure — and nothing is logged at all.
|
||
|
||
Both apply in every environment, including under Vitest: neither is a `NODE_ENV=test` special case.
|
||
A focused unit test (`core/tests/identity-runtime.test.ts`) simulates a hostile log call (a raw
|
||
exception carrying a password, a host, a port, a filesystem path, and a rejected origin) and asserts
|
||
none of it reaches `console.error`, and separately asserts `onAPIError` is configured; a live
|
||
integration test forces a real identity-database failure through Better Auth's own sign-in handler
|
||
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
|
||
|
||
## Client-address resolution
|
||
|
||
Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned
|
||
release's own client-address resolution only ever reads request headers — it cannot see the real
|
||
TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport
|
||
boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to
|
||
`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other
|
||
route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`)
|
||
is framework-agnostic and lives in Core; only the Node-specific glue that reads
|
||
`env.incoming.socket.remoteAddress` lives in `apps/api`.
|
||
|
||
- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only
|
||
the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`,
|
||
`X-Real-IP`, or any other request header is ever consulted for identity.
|
||
- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with
|
||
`EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required).
|
||
A forwarded header's content is trusted **only** when the actual immediate socket peer matches
|
||
one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is
|
||
used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**:
|
||
trailing hops matching a trusted proxy are skipped, and the first hop from the right that does
|
||
not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops
|
||
chain from an otherwise-trusted peer fails closed with a sanitized
|
||
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all
|
||
unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a
|
||
dual-stack socket or a real proxy actually emits) all normalize consistently before comparison.
|
||
|
||
Whichever address is resolved is written into one fixed internal header
|
||
(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`),
|
||
**overwriting** — never appending to — any externally supplied copy of that exact header name, so
|
||
a caller can never choose its own "resolved" identity by sending it directly. Better Auth is
|
||
configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own
|
||
default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects
|
||
header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md)
|
||
for why that is insufficient on its own).
|
||
|
||
**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the
|
||
configured forwarded header**, and the origin must not be reachable except through that proxy —
|
||
neither is something application-level validation can enforce; both are the deployment's own
|
||
network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes
|
||
three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy
|
||
mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns).
|
||
|
||
## Rate limiting
|
||
|
||
Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
|
||
it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on
|
||
sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the
|
||
integration test suite injects a narrow, explicit override so it is not itself rate-limited by its
|
||
own many requests. Storage remains the default in-memory backend, which is **per-process**: no
|
||
protection across multiple API instances or restarts. Client identity for this limiter is now
|
||
always the resolved, server-owned address described above — never a caller-controlled header — so
|
||
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 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
|
||
|
||
Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
|
||
platform's own limiter (`apps/api/src/body-limits.ts`):
|
||
|
||
| Surface | Limit |
|
||
| --- | --- |
|
||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||
| A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
|
||
|
||
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
|
||
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
|
||
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||
|
||
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
|
||
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
|
||
available body within its declared allowance, and the platform holds at most that allowance.
|
||
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
|
||
moment the total passes the limit the stream errors, the source is cancelled, and the response
|
||
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
|
||
therefore arrive *after* a streamed handler has started, which is the one consequence a module
|
||
author must design for.
|
||
|
||
An oversized request always receives the identical stable, sanitized contract —
|
||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||
Auth's business logic, a Core mutation, permission evaluation (where avoidable), a module handler,
|
||
or module database behavior. For a state-changing route the body limit is enforced after
|
||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||
is never read for size at all, while an oversized same-origin request still fails before any
|
||
authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
|
||
handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
|
||
through the platform at all.
|
||
|
||
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
|
||
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
|
||
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
|
||
request rather than a `413`, because nothing exceeded a limit.
|
||
|
||
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
|
||
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
|
||
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
|
||
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
|
||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
|
||
|
||
## Node request and header timeouts
|
||
|
||
`apps/api/src/server.ts` configures the underlying Node HTTP server (via `@hono/node-server`'s
|
||
`serverOptions`, passed straight through to `http.createServer`) with finite
|
||
`headersTimeout` (20s), `requestTimeout` (30s), and `keepAliveTimeout` (5s, Node's own default,
|
||
made explicit). Node's own documented behavior responds `408` and closes the connection without
|
||
ever invoking a request listener when either timeout is exceeded, so a timed-out connection cannot
|
||
invoke a handler or receive a second response by construction.
|
||
|
||
Two non-obvious details, both found only by reproducing them directly against the pinned Node 24
|
||
runtime through a real socket:
|
||
|
||
1. **Detection latency.** Node's `http.Server` only *checks* for a timeout violation when its
|
||
periodic `connectionsCheckingInterval` fires, and that option's own default is 30 seconds —
|
||
leaving it unset would silently let a violating connection sit for up to a further 30 seconds
|
||
past its configured timeout. `connectionsCheckingIntervalMs` is therefore also set explicitly
|
||
(5s).
|
||
2. **The observable event is `'clientError'`, not `'timeout'`.** Node's general server `'timeout'`
|
||
event belongs to `server.setTimeout()`'s idle-socket timer, which this server never arms; it
|
||
does **not** fire for `headersTimeout`/`requestTimeout`. Those surface as `'clientError'` with
|
||
`err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. `apps/api/src/server.ts` therefore observes
|
||
`'clientError'`, and — because registering any `'clientError'` listener *suppresses* Node's own
|
||
default response entirely (`_http_server.js` only falls back when the emit returns `false`) —
|
||
that handler reproduces Node's default byte-for-byte rather than merely watching: `408 Request
|
||
Timeout` for a timeout, `431 Request Header Fields Too Large` for a header overflow, and `400
|
||
Bad Request` otherwise, each followed by destroying the socket, and each written only when the
|
||
socket is still writable and no response has already begun (so a handler that already responded
|
||
before an unread body timed out never receives a second, corrupting status line).
|
||
|
||
Exactly one fixed, sanitized operational line is logged per transport-level client failure — one
|
||
message for a timeout, one for any other malformed/oversized request. Neither ever contains the
|
||
request path, query, body, cookies, authorization value, client-address header, forwarding chain,
|
||
raw peer address, stack, or the caught error's own message (a parse failure's message is derived
|
||
from attacker-controlled input). This is proven at the real transport boundary by raw-socket
|
||
scenarios in `apps/api/tests/api-http.integration.ts` — a real slow-header connection, a real
|
||
slow-body connection, a malformed request line, and an oversized header block — each asserting the
|
||
exact response bytes, exactly one status line, socket closure, no handler invocation, and exactly
|
||
one sanitized log. See
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) for the full
|
||
account.
|
||
|
||
## Module discovery, permission composition, and module runtime startup
|
||
|
||
`startApi()` composes production in one fixed order:
|
||
|
||
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||
`apps/api/src/platform-permissions.ts`.
|
||
2. Construct the one shared PostgreSQL pool.
|
||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||
pool (see [Module database access](#module-database-access)).
|
||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||
5. Construct the host object-storage provider from validated configuration, if this installation has
|
||
one — building the client opens no connection, so a storage outage can never stop the platform
|
||
from booting (ADR 0030). Then read the stored module enablement configuration and the stored module
|
||
configuration, and resolve, over that exact same discovered inventory, which modules are
|
||
**eligible** to run and what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
|
||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle) and
|
||
[Module configuration](#module-configuration)). The two reads are sequential rather than raced so
|
||
a broken deployment reports the same error code every time.
|
||
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||
hook receives.
|
||
7. Create the Hono application, mounting the started module runtime's HTTP routes and realtime
|
||
channels, publishing UI for that same eligible set, and passing the identical scoped runtime
|
||
objects to module HTTP handlers.
|
||
8. Bind the HTTP server.
|
||
|
||
Discovery and permission composition happen before any pool, Better Auth instance, module
|
||
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
|
||
manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission
|
||
declaration, ...) fails startup outright, before there is anything to close; startup never falls
|
||
back to a Core-only registry. Core infrastructure exists before any executable module lifecycle
|
||
code runs, and every module route is published only after the *entire* module runtime has
|
||
registered and started successfully, so no request can ever reach a module before every module has
|
||
started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||
for the permission-composition detail, and [Module development](module-development/README.md) for
|
||
the manifest shape that produces these declarations.
|
||
|
||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||
successful production startup does import each **eligible** module's compiled server entrypoint and
|
||
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
|
||
module code, and only after every other startup step already listed has succeeded. A module that is
|
||
disabled, or blocked by a disabled required dependency, is never imported at all.
|
||
|
||
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
||
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
|
||
(and `MODULE_CONFIGURATION_READ_FAILED` if the enablement read succeeded and the configuration read
|
||
did not) rather than starting every discovered module. The platform cannot know what an administrator
|
||
decided, and starting a module they disabled is worse than not starting. Nothing is created or
|
||
repaired on that path.
|
||
|
||
## Module lifecycle
|
||
|
||
```text
|
||
GET /api/v1/module-lifecycle
|
||
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
|
||
```
|
||
|
||
The platform's operational view of its installed modules
|
||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what
|
||
this process actually did with each of them, and whether the two currently disagree.
|
||
|
||
It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is
|
||
(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could
|
||
otherwise register `PUT /enabled` and collide with the platform at exactly
|
||
`/api/v1/modules/announcements/enabled`.
|
||
|
||
```json
|
||
{
|
||
"modules": [
|
||
{
|
||
"id": "storage",
|
||
"version": "1.0.0",
|
||
"configured": { "enabled": false, "explicit": true },
|
||
"runtime": { "state": "disabled" },
|
||
"restartRequired": false
|
||
},
|
||
{
|
||
"id": "announcements",
|
||
"version": "1.0.0",
|
||
"configured": { "enabled": true, "explicit": false },
|
||
"runtime": {
|
||
"state": "blocked",
|
||
"reason": "required_dependency_disabled",
|
||
"dependency": { "kind": "module", "moduleId": "storage" }
|
||
},
|
||
"restartRequired": false
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Two axes, deliberately not one status word
|
||
|
||
`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and
|
||
the platform default (**enabled**) applies. `runtime` is what *this process* did:
|
||
|
||
| state | meaning |
|
||
| ---------- | ----------------------------------------------------------------------------- |
|
||
| `running` | the module was eligible and its `start` hook completed in this process |
|
||
| `disabled` | configuration says it must not run, so it was never imported |
|
||
| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` |
|
||
|
||
`running` says the `start` hook completed. It says **nothing** about whether every external resource
|
||
that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this
|
||
state must not be read as one. There is deliberately no `failed` state: module startup is
|
||
all-or-nothing, so a module that fails to start aborts API startup and no live process could ever
|
||
report it.
|
||
|
||
`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or
|
||
`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always
|
||
terminates at a `..._disabled` entry. An **optional** dependency never blocks anything.
|
||
|
||
### Changing enablement requires a restart, and the response says so
|
||
|
||
There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about
|
||
the running process:
|
||
|
||
```json
|
||
{ "id": "announcements", "version": "1.0.0",
|
||
"configured": { "enabled": false, "explicit": true },
|
||
"runtime": { "state": "running" },
|
||
"restartRequired": true }
|
||
```
|
||
|
||
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
|
||
routes. `restartRequired` compares what the current configuration *would* start against what the
|
||
runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its
|
||
dependents, because they really will stop on the next boot.
|
||
|
||
A module whose enablement changed but whose process has not restarted stays fully exposed — routes,
|
||
artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer
|
||
mid-session for a change that has not taken effect.
|
||
|
||
### What a disabled module does not have
|
||
|
||
Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an
|
||
unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere);
|
||
no capabilities are registered; `GET /api/v1/module-ui/<id>/<version>` returns the same `404` an
|
||
uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and
|
||
`GET /api/v1/dashboard/navigation`.
|
||
|
||
Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime
|
||
enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would
|
||
require a schema change in the middle of a restart. Its **permissions also stay registered**, so
|
||
grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces
|
||
them, because it mounts no routes.
|
||
|
||
### Authority
|
||
|
||
Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too,
|
||
because this resource is the only place a disabled module, a blocked module, and the
|
||
required-dependency edges between them are visible at all — `core.modules.ui.read` deliberately
|
||
covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded
|
||
body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins.
|
||
|
||
The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped.
|
||
A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` —
|
||
enablement is operational configuration, not an unbounded key-value store. Existing records for
|
||
modules that are no longer deployed are retained and ignored, so redeploying restores the decision.
|
||
|
||
No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or
|
||
stack.
|
||
|
||
### Recovering from a module that breaks startup
|
||
|
||
Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this
|
||
API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner`
|
||
(explicit, never HTTP-exposed):
|
||
|
||
```bash
|
||
pnpm modules:disable -- --module announcements
|
||
pnpm modules:enable -- --module announcements
|
||
```
|
||
|
||
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
||
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
||
|
||
## Module configuration
|
||
|
||
```text
|
||
GET /api/v1/module-configuration/<module-id>
|
||
PUT /api/v1/module-configuration/<module-id> { "values": { "slideSeconds": 12 } }
|
||
```
|
||
|
||
What one installed module declares it needs configured, what an operator has stored, and whether the
|
||
running process is still using it
|
||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||
|
||
A **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` and
|
||
`/api/v1/module-lifecycle` are: `config` and `configuration` are legal module route segments, so a
|
||
module could otherwise collide with the platform at `/api/v1/modules/<id>/config`.
|
||
|
||
```json
|
||
{
|
||
"moduleId": "announcements",
|
||
"version": "1.0.0",
|
||
"keys": [
|
||
{ "key": "slideSeconds", "type": "integer", "required": false, "default": 7,
|
||
"description": "How long each slide is shown.", "configured": false },
|
||
{ "key": "bucket", "type": "string", "required": true, "value": "slides", "configured": true },
|
||
{ "key": "storageCredential", "type": "secret", "required": true, "configured": true }
|
||
],
|
||
"unknownKeys": [],
|
||
"restartRequired": false
|
||
}
|
||
```
|
||
|
||
### A secret's value is absent, not redacted
|
||
|
||
Note what the `storageCredential` entry above does **not** have: a `value` field. Not `null`, not
|
||
`"***"` — absent. Ekklesia never persists secret material, so the store this route reads from
|
||
contains none, and there is no code path that could serve one. `configured` answers the only question
|
||
an operator needs from here: does the deployment supply it?
|
||
|
||
### The declared types
|
||
|
||
| type | notes |
|
||
| --- | --- |
|
||
| `string` | optional `minLength` / `maxLength` |
|
||
| `integer` | optional `minimum` / `maximum`; submitted as a JSON number, never a string |
|
||
| `boolean` | submitted as a JSON boolean |
|
||
| `enum` | closed `values` list |
|
||
| `url` | absolute `http:`/`https:` only |
|
||
| `secret` | deployment-supplied; never stored, never returned, never defaulted |
|
||
|
||
No nesting, no arrays, no regular expressions. This is a validation vocabulary, not a form-builder
|
||
DSL; a module needing structured data owns a table for it.
|
||
|
||
### Writing is a whole-document replace, with closed validation
|
||
|
||
A key the module does not declare is `400 CONFIGURATION_KEY_UNDECLARED`. A wrong JSON type or a value
|
||
failing the module's own declared constraints is `400 CONFIGURATION_VALUE_INVALID`. A `secret` key is
|
||
`400 CONFIGURATION_SECRET_NOT_WRITABLE` — there is no supported way to submit secret material to this
|
||
platform. Nothing is coerced: `{"slideSeconds": "7"}` is a `400`, not a silent `7`.
|
||
|
||
Constraint checking runs through **the same resolver startup uses**, over the exact document about to
|
||
be stored, so the API and startup can never disagree about whether a value is valid.
|
||
|
||
An *incomplete* document is accepted deliberately — an operator may save partial work, and the module
|
||
is reported `blocked · configuration_missing` until they finish. An *invalid* one never is.
|
||
|
||
A key omitted from the submission is deleted. Stored keys the module's current manifest no longer
|
||
declares are **retained and ignored**, and listed as `unknownKeys`, so a downgrade or a rename does
|
||
not destroy an operator's value.
|
||
|
||
### Changes take effect at the next start
|
||
|
||
Modules receive a configuration snapshot resolved once at startup, so a `PUT` changes nothing about
|
||
the running process and the response reports `restartRequired` rather than pretending otherwise.
|
||
Nothing is hot-reloaded. A setting that genuinely must change live is the module's own domain state,
|
||
stored in the module's own tables and exposed through its own route — not platform configuration.
|
||
|
||
### Missing or invalid configuration blocks the module
|
||
|
||
An enabled module whose required configuration cannot be satisfied is never imported. It appears in
|
||
`GET /api/v1/module-lifecycle` as `blocked` with a structured reason naming the offending **key**:
|
||
|
||
```json
|
||
{ "runtime": { "state": "blocked", "reason": "configuration_missing", "configurationKey": "bucket" } }
|
||
```
|
||
|
||
`configuration_missing` (set a value), `configuration_invalid` (correct the value), and
|
||
`secret_missing` (set it in the deployment environment) are separate reasons because each has a
|
||
different fix. A blocked module blocks its required dependents exactly like a disabled one does.
|
||
|
||
### Secrets come from the deployment
|
||
|
||
A module declares a `secret` key; the host maps it to a deployment-supplied value. In an Ekklesia
|
||
deployment that mapping is an environment variable:
|
||
|
||
```text
|
||
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||
```
|
||
|
||
The module never sees that name — it calls `config.getSecret("storageCredential")` — which is what
|
||
lets a future standalone host supply the same declared secret from somewhere else entirely.
|
||
|
||
### Authority
|
||
|
||
Both verbs require `core.modules.configuration.manage` (baseline `restricted`), deliberately separate
|
||
from `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it behaves are
|
||
different authorities. Mutations additionally require an exact trusted `Origin` and a bounded body.
|
||
|
||
No response carries a filesystem path, package name, manifest path, caught error, stack, or secret
|
||
value.
|
||
|
||
## Module object storage
|
||
|
||
Ekklesia can provide modules with logical object storage backed by any S3-compatible service
|
||
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)). There is **no
|
||
HTTP API for it**: storage is a runtime capability modules use, not a resource the platform exposes,
|
||
and no route returns a provider endpoint or credential.
|
||
|
||
### Host configuration
|
||
|
||
| variable | meaning |
|
||
| --- | --- |
|
||
| `S3_ENDPOINT` | Absolute `http`/`https` URL. **Absent means this installation has no object storage.** |
|
||
| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Required once an endpoint is set |
|
||
| `S3_REGION` | Defaults to `us-east-1` |
|
||
| `S3_FORCE_PATH_STYLE` | Defaults to `true`; self-hosted services need it |
|
||
| `S3_BUCKET_PREFIX` | Defaults to `ekklesia`; namespaces this installation on a shared service |
|
||
| `S3_DELIVERY_ENDPOINT` | The origin a **browser** reaches storage on. Defaults to `S3_ENDPOINT`; set it only when that is not browser-reachable |
|
||
|
||
Production requires `https`, exactly as the database transport requires verified TLS. An endpoint
|
||
embedding credentials is rejected rather than stripped. These values reach Core's provider and
|
||
nothing else — never a module, module UI, browser bundle, diagnostic, or API response.
|
||
|
||
The repository's development Compose stack already runs SeaweedFS for this, and `.env.example`
|
||
already carries matching values.
|
||
|
||
`S3_DELIVERY_ENDPOINT` exists because an S3-compatible signature covers the request's `Host`, so a
|
||
delivery URL cannot be signed for one origin and rewritten to another — the browser-facing origin has
|
||
to be known when the URL is signed. Leave it unset whenever `S3_ENDPOINT` is itself reachable from a
|
||
browser, which is true of the development stack (Compose publishes storage on loopback) and of any
|
||
deployment where Core and browsers use the same name. Set it when Core reaches storage privately —
|
||
`http://object-storage:8333` — and browsers reach it publicly. The scheme is *not* signed, so a
|
||
reverse proxy may terminate TLS and forward plaintext, **provided it passes the original `Host`
|
||
header through unchanged**; a proxy that rewrites `Host` to its backend makes every delivery URL fail
|
||
with `403`.
|
||
|
||
### Logical buckets, and what modules get
|
||
|
||
A module declares logical buckets in its manifest (`storage.buckets`) and receives a scoped facade at
|
||
`context.storage` on its runtime and HTTP handler contexts. It names a logical bucket; the host maps
|
||
it to a physical one deterministically and never exposes that name.
|
||
|
||
An installation with **no** provider is a complete deployment: modules declaring no bucket run
|
||
normally. A module that *does* declare one is reported `blocked · storage_unavailable` on
|
||
`GET /api/v1/module-lifecycle`, naming the logical bucket:
|
||
|
||
```json
|
||
{ "runtime": { "state": "blocked", "reason": "storage_unavailable",
|
||
"storageBucketId": "announcement-assets" } }
|
||
```
|
||
|
||
Its required dependents block behind it through the ordinary dependency mechanism.
|
||
|
||
### Binding availability is not provider health
|
||
|
||
Eligibility asks whether storage is **configured**, never whether the service answered. A configured
|
||
provider that is temporarily unreachable is an operational error the module catches and retries —
|
||
`running` still means the `start` hook completed, and no lifecycle state changes because a storage
|
||
call timed out. Buckets are created lazily on first use rather than at startup, so a storage outage
|
||
never prevents the platform from booting, and nothing ever deletes a bucket.
|
||
|
||
### Serving objects to browsers
|
||
|
||
A module can hand a browser a temporary URL and let it fetch the object from storage directly
|
||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)):
|
||
|
||
```text
|
||
browser → GET /api/v1/modules/<id>/slides → module route → { key, url, expiresAt }
|
||
browser → GET <url> ─────────────────────────────────────→ storage service
|
||
```
|
||
|
||
The module authorizes the request in its own route, asks its logical bucket for a URL, and returns
|
||
it. Ekklesia is not in the data path, so byte ranges, caching, and revalidation are the storage
|
||
service's job — a `Range` request against a delivery URL is answered directly with `206`, which is
|
||
what makes media seeking work without a range proxy in Core.
|
||
|
||
```ts
|
||
const assets = context.storage.bucket("announcement-assets");
|
||
const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");
|
||
```
|
||
|
||
A delivery URL is a **bearer capability**: whoever holds it can read that one object until it
|
||
expires. Deciding who may have one is the module's authorization decision, made before it asks;
|
||
the storage contract only bounds how far the URL reaches. Treat a complete URL as a secret — never
|
||
log one (see [Security](security.md#object-storage-is-scoped-by-binding-and-modules-never-hold-credentials)).
|
||
|
||
Streaming through the module's own route with `context.storage.get(...)` remains available and is
|
||
still the right choice when the response must be computed, transformed, or authorized per byte.
|
||
There are no signed **write** URLs: uploads go through a module route, where the module can validate
|
||
what it is accepting.
|
||
|
||
## Display device pairing and management
|
||
|
||
Host-owned, and reachable by no module
|
||
([ADR 0036](../architecture/decisions/0036-managed-device-identity-and-pairing.md)). Two sibling
|
||
prefixes: `/api/v1/device-pairing` and `/api/v1/devices`.
|
||
|
||
**The screen's side** (no identity required; authority is the HttpOnly claim cookie the first call
|
||
sets, never the code):
|
||
|
||
| route | purpose |
|
||
| --- | --- |
|
||
| `POST /api/v1/device-pairing/requests` | Body `{moduleId}`. Returns `{code, expiresAt, pollIntervalMs}` and sets `ekklesia_device_pairing`. The permission is read from that module's validated display declaration — a request cannot name one. |
|
||
| `POST /api/v1/device-pairing/claims` | Polls. Returns `{status}`; on `paired` it sets `ekklesia_device_session` and clears the pairing cookie. |
|
||
|
||
**The administrator's side** (requires the restricted `core.devices.manage` and an exact trusted
|
||
`Origin`):
|
||
|
||
| route | purpose |
|
||
| --- | --- |
|
||
| `POST /api/v1/device-pairing/lookups` | Body `{code}`. Shows the module and permission a screen is asking for, before approving. |
|
||
| `POST /api/v1/device-pairing/approvals` | Body `{code, deviceName}`. The administrator names the screen; the screen never names itself. |
|
||
| `GET /api/v1/devices` | Paired devices: name, permissions, created, last seen, revoked. Never a credential. |
|
||
| `POST /api/v1/devices/:deviceId/revoke` | `204`. Server-side revocation, checked on every principal resolution. |
|
||
|
||
The pairing code travels in request **bodies**, never a path or query, so it does not land in access
|
||
logs, browser history, or a `Referer`. Expired, consumed, mistyped, and never-existed codes all
|
||
answer the same way.
|
||
|
||
`/api/v1/principal` deliberately reports a device as `anonymous`: it answers a question about
|
||
*people*, and the Dashboard depends on that. A display reads its viewer from the display descriptor
|
||
instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "device", deviceId}`.
|
||
|
||
**Cookies.** `ekklesia_device_session` is `HttpOnly`, `SameSite=Strict`, host-only, `Path=/`, and
|
||
`Secure` when the identity base URL is https. Deployment note: because it is host-only and
|
||
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
|
||
own `/display/<module-id>` route is.
|
||
|
||
## Outbound email
|
||
|
||
There is **no HTTP API for it**, deliberately
|
||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Email is a runtime
|
||
capability trusted server-side features call, not a resource the platform exposes: an authenticated
|
||
send endpoint is an open relay with extra steps, so no route exists and no route file imports the
|
||
mail module. A test asserts both.
|
||
|
||
Host configuration is `EKKLESIA_SMTP_HOST`, `EKKLESIA_SMTP_PORT`, `EKKLESIA_SMTP_SECURITY`,
|
||
`EKKLESIA_SMTP_USERNAME`/`EKKLESIA_SMTP_PASSWORD` (optional as a pair), `EKKLESIA_SMTP_FROM_ADDRESS`,
|
||
and `EKKLESIA_SMTP_FROM_NAME`. Absent `EKKLESIA_SMTP_HOST` means this installation sends no email,
|
||
which is a complete deployment. None of these values ever reaches a module, a browser bundle, a
|
||
diagnostic, or an API response. See [Outbound email](email.md).
|
||
|
||
## Module HTTP routes
|
||
|
||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
|
||
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
|
||
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
|
||
|
||
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
|
||
routes use, with the route's own declared permission id as the only permission ever consulted —
|
||
its trusted baseline always comes from the composed platform registry, never from the route or
|
||
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
|
||
the exact same path; a public-baseline route still resolves and receives an authorized principal.
|
||
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
|
||
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
|
||
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
|
||
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||
- **Body limit.** A route's own `maxBodyBytes` (see [Module development](module-development/README.md#http-routes)
|
||
and [Request body limits](#request-body-limits)) is enforced immediately after the trusted-origin
|
||
check and before permission evaluation — `0` means no body is accepted at all. An oversized
|
||
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
|
||
authorization, the handler, or the module database.
|
||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and body
|
||
all behave normally, and the full query string remains available) plus a frozen context of exactly
|
||
`moduleId`, the authorized `EkklesiaPrincipal`, frozen Hono-decoded path parameters (an empty
|
||
frozen object for a route with none), `services`, and the module's scoped configuration, storage,
|
||
and realtime publisher. It never receives a Hono `Context`, authorization/grant stores, jobs, or
|
||
another module's scoped objects. Core does not serialize configuration or secrets into responses.
|
||
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
|
||
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
|
||
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
|
||
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
|
||
own response is stripped before the response leaves the process. Every module response is forced
|
||
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
|
||
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
|
||
dedicated cache-policy contract exists, since even a public-baseline handler receives an
|
||
authorized principal and may personalize its response.
|
||
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
|
||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||
|
||
## Module realtime
|
||
|
||
An eligible module may declare a module-local state channel during `register`. Ekklesia mounts one
|
||
exact route for each successfully started declaration:
|
||
|
||
```text
|
||
GET /api/v1/module-realtime/<module-id>/<channel>
|
||
```
|
||
|
||
The path is host-private. Module UI receives the module's server-produced realtime base in its mount
|
||
descriptor and calls `context.realtime.subscribe("state", handlers)`; it never builds this URL,
|
||
chooses a module id, supplies a permission, or sees an EventSource. Browser commands remain ordinary
|
||
module HTTP requests, retaining their body, origin, authorization, and response semantics.
|
||
|
||
The response is a Server-Sent Events stream. It begins with exactly one authoritative `snapshot`
|
||
event and then carries `update` events:
|
||
|
||
```text
|
||
event: snapshot
|
||
data: {"sequence":1,"payload":{"mode":"on","currentSlide":0}}
|
||
|
||
event: update
|
||
data: {"sequence":2,"payload":{"mode":"on","currentSlide":1}}
|
||
```
|
||
|
||
Core atomically captures the latest state while installing the subscriber, so there is no separate
|
||
GET-then-subscribe gap. Sequence is monotonically increasing per channel for this process only; there
|
||
is no `id` field, `Last-Event-ID` replay, durable history, or exactly-once promise. A reconnect is a
|
||
new subscription and begins with the latest snapshot. Slow clients retain only the newest pending
|
||
update, so sequence gaps are expected and intermediate states may be coalesced.
|
||
|
||
- **Authentication and authorization.** The SSE GET uses the existing session cookie and the same
|
||
principal resolver, permission registry, and evaluator as module HTTP. The channel's declaration
|
||
fixes the permission before any request exists. Permission evaluation completes before a Core
|
||
subscriber is allocated. There is no query-string token or second realtime auth system.
|
||
- **Reauthorization.** A stream has a host-owned maximum age of five minutes. The host closes it at
|
||
that boundary; native EventSource reconnects and the ordinary cookie and permission checks run
|
||
again. Grant revocation, grant expiry, and session changes therefore do not leave an accepted
|
||
stream authorized forever.
|
||
- **Bounds.** Core accepts at most 16 channels per module, 100 subscribers per channel, and 64 KiB
|
||
of serialized JSON per publication. Publishing never awaits a browser. One latest snapshot and
|
||
one pending update per subscriber are the only state retained.
|
||
- **Transport behavior.** Responses use `text/event-stream; charset=utf-8`, `Cache-Control:
|
||
no-store`, `Vary: Origin, Cookie`, `X-Accel-Buffering: no`, and a 15-second comment keepalive.
|
||
Keepalives consume no module sequence and never reach module UI. A reverse proxy must forward
|
||
streaming bodies incrementally and permit that idle interval. No proxy product is part of the
|
||
module contract.
|
||
- **Methods and errors.** Hono normally lets `HEAD` reuse GET handlers; this route explicitly returns
|
||
`405` before allocating a stream because discarding an infinite body could leak a subscriber.
|
||
Subscriber exhaustion is the fixed `503 {"code":"REALTIME_SUBSCRIBER_LIMIT"}`; other stopped or
|
||
unavailable runtime states are `503 {"code":"REALTIME_UNAVAILABLE"}`. Raw errors, paths, URLs,
|
||
payloads, permissions, secrets, and stacks are never reflected.
|
||
- **Lifecycle.** Disabled and blocked modules have no route because their entrypoint never runs and
|
||
their channel is never mounted. Shutdown first refuses and closes realtime subscriptions so they
|
||
cannot hold HTTP drain open. Ordinary in-flight module HTTP handlers may finish and publish during
|
||
that drain. Core then stops modules in reverse dependency order, deactivating each module's scoped
|
||
publisher immediately before its own stop hook; a captured publisher fails predictably afterward.
|
||
|
||
Realtime carries opaque module state. If a module includes a direct object-delivery URL, the exact
|
||
serialized string passes through unchanged: Core neither re-signs it per subscriber nor extends its
|
||
expiry. Image, video, and audio bytes remain object-storage traffic, never realtime payloads. See
|
||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
|
||
|
||
## Dashboard navigation
|
||
|
||
```text
|
||
GET /api/v1/dashboard/navigation
|
||
PUT /api/v1/dashboard/navigation/groups/<group-id> { "label": "Media", "order": 10 }
|
||
DELETE /api/v1/dashboard/navigation/groups/<group-id>
|
||
PUT /api/v1/dashboard/navigation/placements/<module-id> { "groupId": "media"|null, "order": 10 }
|
||
```
|
||
|
||
How this installation presents its mountable modules
|
||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)).
|
||
|
||
```json
|
||
{
|
||
"groups": [
|
||
{
|
||
"id": "media",
|
||
"label": "Media",
|
||
"order": 10,
|
||
"modules": [
|
||
{
|
||
"name": "Livestream",
|
||
"module": {
|
||
"id": "livestream",
|
||
"version": "1.0.0",
|
||
"ui": { "url": "/api/v1/module-ui/livestream/1.0.0" },
|
||
"api": { "baseUrl": "/api/v1/modules/livestream" },
|
||
"realtime": { "baseUrl": "/api/v1/module-realtime/livestream" },
|
||
"permissions": ["livestream.streams.read"]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"ungrouped": []
|
||
}
|
||
```
|
||
|
||
### Navigation versus inventory
|
||
|
||
They are deliberately separate, and this route is layered **on top of** the inventory rather than
|
||
merged into it:
|
||
|
||
- `GET /api/v1/module-ui` answers *what may be mounted*, host-neutrally. A standalone host consumes
|
||
it and never learns Ekklesia has folders. It carries no display name, group, or order.
|
||
- `GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and
|
||
**embeds** each generic descriptor untouched under `module`.
|
||
|
||
Both compose that descriptor through one shared function, so they cannot disagree about what exists
|
||
or about any URL.
|
||
|
||
### Grouping is presentation, never module structure
|
||
|
||
A group is a label on a rendering bucket. It is **not a module**: it has no manifest, version,
|
||
artifact, API, permission, or lifecycle, and nothing depends on it. Placing Livestream and Video in
|
||
one folder creates no relationship between them, and moving a module changes a single presentation
|
||
row — never its artifact, API root, permissions, dependencies, or startup order. Real technical
|
||
dependencies remain the module dependency contract's job and are the only relationship modules have.
|
||
|
||
### Resolution rules
|
||
|
||
- A module with **no placement is ungrouped and visible** — installing it is enough, with no
|
||
initialization write and no manual database edit.
|
||
- A stored placement for a module that is **not installed renders nothing and is retained**, so
|
||
uninstall-and-reinstall preserves an arrangement.
|
||
- **Empty groups are kept** (hiding one would look like deletion), and a placement naming a missing
|
||
group renders as ungrouped rather than disappearing.
|
||
- Deleting a group **ungroups its modules** through the schema's `on delete set null`; a dangling
|
||
reference is impossible.
|
||
- Ordering is total: groups by `(order, id)`, modules by `(configured, order, id)` — placed modules
|
||
in the administrator's order, then never-configured ones by id, with the identifier always the
|
||
final tie-break, so colliding order values are still deterministic.
|
||
|
||
### Authority
|
||
|
||
Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this
|
||
response contains it and serves the same audience. Organizing requires
|
||
`core.dashboard.navigation.manage` (baseline `restricted`), a **platform** permission and never a
|
||
module's, plus an exact trusted `Origin` and a bounded body. Holding it grants no module authority:
|
||
every module route stays enforced against its own declared permission, and no arrangement of folders
|
||
changes an authorization decision.
|
||
|
||
Group ids are opaque, stable, lowercase kebab-case keys, never displayed and never re-derived from a
|
||
label — so renaming a group cannot re-organize the Dashboard. Bodies are closed: any key beyond the
|
||
declared ones makes the whole request invalid rather than being silently dropped. Configuration is
|
||
platform-global; Ekklesia has no organization or tenant model to scope it to yet.
|
||
|
||
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||
|
||
## Module UI inventory
|
||
|
||
```text
|
||
GET /api/v1/module-ui
|
||
```
|
||
|
||
The one query a browser host makes before it can mount anything
|
||
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). It is the collection
|
||
root of the same resource the artifact route below serves: one prefix, two representations.
|
||
|
||
```json
|
||
{
|
||
"modules": [
|
||
{
|
||
"id": "example",
|
||
"version": "0.1.0",
|
||
"ui": { "url": "/api/v1/module-ui/example/0.1.0" },
|
||
"api": { "baseUrl": "/api/v1/modules/example" },
|
||
"realtime": { "baseUrl": "/api/v1/module-realtime/example" },
|
||
"permissions": ["example.records.read"]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- **Six fields, each because a `ModuleUiHostContext` needs it.** No filesystem path, package root,
|
||
package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And
|
||
no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are navigation metadata,
|
||
and [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard
|
||
grouping an administrator's presentation configuration rather than a module declaration.
|
||
- **All three URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl`, `api.baseUrl`
|
||
from `moduleApiBaseUrl`, and `realtime.baseUrl` from `moduleRealtimeBaseUrl`, so each URL shape is
|
||
constructed in exactly one place in the repository. A browser never rebuilds any platform module
|
||
prefix, which lets one change without touching host or module code.
|
||
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
|
||
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
|
||
Order is the registry's own deterministic discovery order. Dedicated display artifacts are not
|
||
merged into this primary-UI inventory; this pass deliberately adds no display enumeration API.
|
||
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
|
||
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
|
||
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
|
||
this endpoint *is* that enumeration. `authenticated` rather than `restricted` because a shell that
|
||
rendered nothing until an operator granted every user a permission would be unusable.
|
||
- **`permissions` is the subset of that module's own declared permissions the caller holds**,
|
||
evaluated server-side by Core's one authorization evaluator — so baselines, the platform-owner
|
||
bypass, grant expiry, and deny-by-default behave exactly as on a real request. Granted ids only,
|
||
scoped to the module: no module learns the viewer's authorization state anywhere else in the
|
||
platform. **These are presentation hints, never authority**; every module route stays enforced by
|
||
`requirePermission` regardless of what a browser was told.
|
||
- **Failures.** Anonymous or unpermitted callers get `403 {"code":"AUTHORIZATION_DENIED"}` from the
|
||
shared middleware. An authorization-evaluation outage is `503
|
||
{"code":"AUTHORIZATION_EVALUATION_FAILED"}` rather than an empty hint set, because a hint that
|
||
silently read "denied" during an outage would blank the UI and look like a permissions problem.
|
||
- **Per-principal**, so `Cache-Control: no-store` and `Vary: Origin, Cookie`, exactly like
|
||
`/api/v1/principal`. `GET` only; no other method is routed.
|
||
|
||
## Module UI asset publication
|
||
|
||
A module may declare `entrypoints.ui`, a compiled browser ESM artifact
|
||
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md),
|
||
[ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). Core's discovery resolves it
|
||
to an absolute path on the server's filesystem; a browser needs a URL. This route is the bridge
|
||
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)).
|
||
|
||
```text
|
||
GET /api/v1/module-ui/<module-id>/<version>
|
||
```
|
||
|
||
- **Identity, not paths.** `:moduleId` and `:version` are lookup keys handed to a publication
|
||
registry Core builds from the discovered, started module inventory. Only an exact match on **both**
|
||
causes the already-canonical artifact path discovery validated to be read. No request value is ever
|
||
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
|
||
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
|
||
maps are unreachable by construction. **An installed module directory is never a static file
|
||
root**, and only the single primary artifact its manifest declares is ever served. A separately
|
||
declared display artifact uses the exact sibling route described below; neither role can select
|
||
the other's file.
|
||
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
|
||
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
|
||
platform compete for one URL with routing order deciding the winner.
|
||
- **Publishable means discovered and started.** A module directory on disk is not enough: the module
|
||
must be in the validated set the API accepted at startup *and* declare `entrypoints.ui`. There is
|
||
no installer, enable/disable state, or trust store to consult, and none was invented here.
|
||
- **No authentication, deliberately.** Fetching the bundle confers no authority — every module API
|
||
stays behind `requirePermission`, and the host, not the artifact, supplies the principal and
|
||
permissions at mount time. Requiring a cookie would also break cross-origin dynamic `import()`,
|
||
which sends none. The cost, stated plainly: a caller who already knows a module id **and** its exact
|
||
installed version can download that bundle. Unknown module, no declared UI, and wrong version all
|
||
return one byte-identical `404 {"code":"MODULE_UI_NOT_PUBLISHED"}`, so the route is not an
|
||
enumeration oracle.
|
||
- **Response.** `Content-Type: text/javascript; charset=utf-8` with `X-Content-Type-Options:
|
||
nosniff`, and the artifact byte-for-byte — never transformed, re-bundled, minified, rewritten, or
|
||
executed. A module UI whose top level throws is served like any other; the server never imports it.
|
||
- **Caching.** `Cache-Control: no-cache` plus a strong SHA-256 `ETag` over the bytes actually served,
|
||
with RFC 9110 `If-None-Match` handling (wildcard, weak comparison, `304`). Not `immutable`: without
|
||
an installer nothing stops a version being rebuilt with different bytes, and an unrecoverable stale
|
||
cache is worse than one conditional request. A version bump changes the URL anyway.
|
||
- **Artifact changed after discovery.** Discovery validated the artifact at startup; it may vanish
|
||
later. The route opens exactly the previously resolved path once, with no fallback and no
|
||
re-resolution, and a read failure returns a sanitized `503
|
||
{"code":"MODULE_UI_ARTIFACT_UNAVAILABLE"}` carrying no path, `errno`, or caught error.
|
||
|
||
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
|
||
construction of this URL. It accepts identity only and percent-encodes both components. The result is
|
||
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
|
||
the same API base URL it already needs for `ModuleUiApiClient`.
|
||
|
||
Ekklesia's Dashboard surface consumes the primary inventory and mounts these URLs through
|
||
`@ekklesia/module-ui-host`. The dedicated display surface reuses the same loader and ABI but obtains
|
||
its role-specific URL only from the protected descriptor below.
|
||
|
||
## Module display host
|
||
|
||
The manifest carries one independently optional display declaration
|
||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
||
|
||
```json
|
||
{
|
||
"entrypoints": {
|
||
"display": {
|
||
"artifact": "./dist/display/index.js",
|
||
"permission": "announcements.display.view"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
That permission must be declared by the same module and have an `authenticated` or `restricted`
|
||
baseline; `public` is rejected because public/device display identity is deferred. The artifact path
|
||
uses the normal compiled, package-contained entrypoint rules and may not collide with server,
|
||
schema, migrations, jobs, or primary UI. A module may declare primary UI only, display only, both,
|
||
or neither.
|
||
|
||
For every display-capable module in the immutable eligible/running publication registry, API
|
||
composition installs three exact routes:
|
||
|
||
```text
|
||
GET /display/<module-id>
|
||
GET /api/v1/module-displays/<module-id>
|
||
GET /api/v1/module-ui/<module-id>/<version>/display
|
||
```
|
||
|
||
The first is a stable bookmark independent of the installed version. It serves the same frozen,
|
||
module-agnostic full-viewport HTML shell for every module, with no Dashboard application or chrome.
|
||
The second returns the only descriptor that shell needs:
|
||
|
||
```json
|
||
{
|
||
"module": {
|
||
"id": "announcements",
|
||
"version": "0.1.0",
|
||
"display": {
|
||
"url": "/api/v1/module-ui/announcements/0.1.0/display"
|
||
},
|
||
"api": { "baseUrl": "/api/v1/modules/announcements" },
|
||
"realtime": { "baseUrl": "/api/v1/module-realtime/announcements" },
|
||
"permissions": ["announcements.display.view"]
|
||
}
|
||
}
|
||
```
|
||
|
||
- **Page and descriptor authorization.** Both run the ordinary cookie principal through Core's one
|
||
evaluator for exactly the display permission stored in the validated publication record. There
|
||
is no query token, public exception, device principal, or separate authorization system. The
|
||
descriptor's `permissions` remains presentation-only; module HTTP and realtime routes still
|
||
enforce their own declared permissions.
|
||
- **Artifact publication.** The versioned `/display` suffix publishes exactly the canonical display
|
||
artifact, byte-for-byte, with the same MIME, `nosniff`, strong ETag, `no-cache`, sanitized `404`/
|
||
`503`, and anonymous-code policy as primary UI. It never falls back to `entrypoints.ui`, and no
|
||
request component becomes a filesystem path. Fetching code grants no API, realtime, or data
|
||
authority.
|
||
- **Lifecycle and restart.** Unknown, primary-only, disabled, dependency-blocked, and failed-startup
|
||
modules have no display routes and collapse into the normal sanitized `404`. Production reads the
|
||
exact display-host HTML and script once before binding, freezes them, and serves fixed paths rather
|
||
than a directory. Installation, manifest, enablement, and build changes take effect on restart.
|
||
- **Browser behavior.** The shell fetches principal first, then the protected descriptor, and only
|
||
then dynamically imports the supplied artifact. It supplies the same role-neutral
|
||
`ModuleUiHostContext` used by primary UI—API, principal, permissions, and realtime—with no mode
|
||
flag. Failures become fixed states with manual retry. The host fills the viewport but does not
|
||
request the browser Fullscreen API.
|
||
- **Unchanged data paths.** Realtime stays on the permission-gated SSE route above. Signed object
|
||
delivery URLs remain opaque bearer strings generated by module backend code and fetched directly
|
||
from storage; the display host neither proxies nor re-signs them.
|
||
|
||
There is deliberately no display inventory or management API. Named variants, targeting,
|
||
public/device identity, a standalone host, a shared stylesheet contract, Announcements migration,
|
||
and Companion integration remain future work.
|
||
|
||
## Module database access
|
||
|
||
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
|
||
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
|
||
stays declarative and database-free. See
|
||
[Module development](module-development/README.md#database-access) for the module-authoring
|
||
contract; this section covers only how `apps/api` composes and wires it.
|
||
|
||
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
||
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
|
||
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
||
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
||
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
||
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
|
||
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
|
||
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
|
||
own instance).
|
||
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
|
||
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
|
||
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
|
||
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
|
||
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
|
||
lifetime of one running API — never a fresh object per hook or per request, and never a second
|
||
pool or facade.
|
||
- **No database access without authorization.** A handler's context — and therefore
|
||
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
|
||
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
|
||
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
|
||
module's database.
|
||
- **Errors.** A handler-thrown database error is sanitized through the exact same
|
||
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
|
||
error contract. A module that deliberately catches its own database failure may still return any
|
||
valid `Response` of its own choosing.
|
||
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
|
||
Module schema and migration changes still follow the existing generation, validation, policy, and
|
||
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
|
||
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
|
||
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
|
||
complete design and its rejected alternatives.
|
||
|
||
## Database connection limits
|
||
|
||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
|
||
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
|
||
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
|
||
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
|
||
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
|
||
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
|
||
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
|
||
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
|
||
constructed, sanitized the same way an invalid connection string already is. The identity,
|
||
authorization, and module domains all read from this one pool through independent typed Drizzle
|
||
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
|
||
one pool, not two.
|
||
|
||
## Database transport (TLS)
|
||
|
||
`core/src/database/transport.ts` is the one typed policy deciding how *every* Ekklesia executable
|
||
reaches PostgreSQL. It has exactly two modes:
|
||
|
||
| Mode | Meaning | Default in |
|
||
| --- | --- | --- |
|
||
| `verify-full` | TLS required, certificate chain verified, **and** the server's identity verified against the configured host. | production |
|
||
| `disable` | No TLS. Refused outright in production. | development/test |
|
||
|
||
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
|
||
`rejectUnauthorized: false` escape hatch: encryption without identity verification is exactly what
|
||
an interception attack defeats. Every loose or aliased spelling — `VERIFY-FULL`, `" verify-full"`,
|
||
`require`, `prefer`, `no-verify`, `true`, `1`, empty — is rejected with `DATABASE_TLS_MODE_INVALID`
|
||
in *both* environments, so a typo can never quietly become a weaker mode.
|
||
|
||
**Production has no plaintext exception — not even for a loopback database.** `disable` in
|
||
production fails with `DATABASE_TLS_REQUIRED_IN_PRODUCTION` whether `DATABASE_URL` points at a
|
||
remote host, `127.0.0.1`, `localhost`, or `[::1]`. A connection string cannot prove the database is
|
||
actually process-local, so no "same host, therefore safe" carve-out exists.
|
||
|
||
Verification is certificate *and* hostname, not encryption alone. The policy sets
|
||
`rejectUnauthorized: true` and an explicit `checkServerIdentity` pinned to the host parsed from
|
||
`DATABASE_URL`. That second part closes a real gap rather than restating a default: the pinned `pg`
|
||
driver sets TLS `servername` only for non-IP hosts, and Node performs **no** identity check when it
|
||
is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA
|
||
ever signed when you connect by IP address. Both halves are proven against a real TLS-enabled
|
||
PostgreSQL in `core/tests/database-tls.integration.ts`.
|
||
|
||
**`DATABASE_URL` may not carry its own TLS parameters.** `sslmode`, `ssl`, `sslcert`, `sslkey`,
|
||
`sslrootcert`, `sslpassword`, `sslnegotiation`, and `uselibpqcompat` are all rejected
|
||
(case-insensitively) with `DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic: the
|
||
pinned driver merges parsed connection-string values *over* explicitly passed options, so a
|
||
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object. TLS is
|
||
decided in exactly one place, and it is never the URL.
|
||
|
||
For a database behind an internal CA, supply the CA's PEM text in `EKKLESIA_DATABASE_CA`; it is
|
||
added to the trusted set for verification and never appears in any error, log, or `pnpm doctor`
|
||
output. Loading it from a file path or a secret store is out of scope here, as are certificate
|
||
rotation and client-certificate authentication.
|
||
|
||
The same policy is resolved identically by every executable that opens a connection — `apps/api`,
|
||
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
|
||
`pnpm doctor`'s own connection — because they all map the environment through the one shared host
|
||
configuration loader (`core/src/host/configuration.ts`, reached by the small migration commands via
|
||
`core/src/cli/database-transport-environment.ts`). The loader takes the environment **as an
|
||
argument**, so `process.env` stays out of Core's reusable constructors and only executables read
|
||
it. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
|
||
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
|
||
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
|
||
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,
|
||
host, port, credentials, or CA material.
|
||
|
||
TLS protects the *connection*, not the exposure: it does not make an internet-reachable database
|
||
safe, and the network boundary remains the deployment's own responsibility. See
|
||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||
|
||
## Health
|
||
|
||
Two unauthenticated endpoints exist for Compose health checks and external monitoring
|
||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
|
||
deliberately uninformative: neither names a module, version, dependency, error, or connection
|
||
detail, and `pnpm doctor` remains the detailed operator report.
|
||
|
||
| Request | Answer |
|
||
| --- | --- |
|
||
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
|
||
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
|
||
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
|
||
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
|
||
|
||
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
|
||
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
|
||
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
|
||
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
|
||
reaches the handlers.
|
||
|
||
Readiness is `200` only when **both** of the following hold:
|
||
|
||
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
|
||
only after the module runtime has started, so this holds whenever the server is answering. The
|
||
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
|
||
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
|
||
began meanwhile.
|
||
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
|
||
The ping runs on the one shared pool as a single message,
|
||
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
|
||
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
|
||
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
|
||
connection, query, and statement timeouts remain the outer bound, for example for a database
|
||
that cannot be reached at all.
|
||
|
||
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
|
||
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
|
||
without starting another query, until that ping has actually settled. A stalled database therefore
|
||
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
|
||
successful result is never cached: every probe after a settled ping runs a new one.
|
||
|
||
A failed ping is never inspected. The response is the same fixed `503` whether the database
|
||
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
|
||
the failure is logged by the route.
|
||
|
||
## Graceful shutdown
|
||
|
||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one
|
||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
|
||
is called. In order: it refuses new realtime subscribers and closes every existing stream; then
|
||
stops the HTTP listener from accepting new connections and lets finite in-flight requests finish;
|
||
then stops the module runtime in reverse dependency order; then closes storage and the one shared
|
||
PostgreSQL pool (identity, authorization, and the module database facade all read from it — see
|
||
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
|
||
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
|
||
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
|
||
and no module code can run against it afterward.
|
||
|
||
Closing streams and disabling publishers are deliberately separate. Streams are long-lived HTTP
|
||
responses and would otherwise force every healthy shutdown to its drain deadline. Publishers remain
|
||
active during the finite HTTP drain so an already-running module command can commit and publish its
|
||
result without turning a successful request into a false `500`. As reverse-order module stop reaches
|
||
each module, Core disables that module's publisher immediately before its stop hook; dependencies
|
||
remain live while their dependents stop, and a stale publisher fails predictably once its own module
|
||
begins stopping. A module's `stop` hook may still use `services.database` and storage here, since
|
||
those providers have not yet closed.
|
||
|
||
The executable entry point's `SIGINT`/`SIGTERM`
|
||
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
|
||
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
|
||
signal arrived" without sending a real process signal or invoking the real `process.exit`.
|
||
|
||
A startup failure after the database pool was already created always closes that pool before the
|
||
failure propagates: module-services/identity/authorization construction failing (no module runtime
|
||
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
|
||
same pool); the server failing to bind its listen address; or an app-construction failure, both of
|
||
which also stop the module runtime first, since it may have already started successfully. A module
|
||
load/register/start failure uses Core's own rollback — the module whose `start` hook failed is
|
||
stopped first, through its own `stop` hook, then already-started modules in reverse order, each
|
||
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 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)). 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
|
||
{"email":"[email protected]","name":"Alice","password":"<redacted>"}
|
||
→ 200
|
||
{"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"[email protected]", ...}}
|
||
|
||
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
|
||
{"ok":true}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted>
|
||
→ 200
|
||
Cache-Control: no-store
|
||
{"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"}
|
||
|
||
POST /api/auth/sign-out
|
||
Cookie: better-auth.session_token=<redacted>
|
||
Origin: https://dashboard.example.test
|
||
→ 200
|
||
Set-Cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
|
||
{"success":true}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted, now revoked>
|
||
→ 200
|
||
{"kind":"anonymous"}
|
||
```
|
||
|
||
## What's next
|
||
|
||
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. Surface work is underway; the API now
|
||
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
|
||
Content-Security-Policy, 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.
|