# 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//*` | 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//` | 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//` | 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///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/` | 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/` | 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//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/` | 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/` | 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 ``. 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//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//` 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/ PUT /api/v1/module-configuration/ { "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//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____ 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//slides → module route → { key, url, expiresAt } browser → GET ─────────────────────────────────────→ 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/` 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/`, 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// ``` 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/ { "label": "Media", "order": 10 } DELETE /api/v1/dashboard/navigation/groups/ PUT /api/v1/dashboard/navigation/placements/ { "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// ``` - **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/ GET /api/v1/module-displays/ GET /api/v1/module-ui///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":"alice@example.test","name":"Alice","password":""} → 200 {"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"alice@example.test", ...}} POST /api/auth/sign-in/email {"email":"alice@example.test","password":""} → 200 Set-Cookie: better-auth.session_token=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure {"ok":true} GET /api/v1/principal Cookie: better-auth.session_token= → 200 Cache-Control: no-store {"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"} POST /api/auth/sign-out Cookie: better-auth.session_token= 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= → 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.