1260 lines
82 KiB
Markdown
1260 lines
82 KiB
Markdown
# API
|
||
|
||
Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
|
||
one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or
|
||
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see
|
||
[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
|
||
|
||
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
|
||
principal, and sign out; public account creation is also possible once explicitly enabled (see
|
||
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an
|
||
email address, recover a forgotten password, use any provider besides email/password, reach a
|
||
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
|
||
ready for a real public launch**: there is no email delivery service, so an unverified email is
|
||
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
|
||
abuse control exists yet even with signup enabled.
|
||
|
||
## 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_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. |
|
||
| `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\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
|
||
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
|
||
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
|
||
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
|
||
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
|
||
| `PUT /api/v1/module-configuration/<module-id>` | Ekklesia | Replace one module's stored configuration. Takes effect at the next API start. Requires `core.modules.configuration.manage` and a trusted `Origin`. See [Module configuration](#module-configuration). |
|
||
|
||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||
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).
|
||
|
||
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
|
||
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
|
||
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
|
||
Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email,
|
||
name, image, session id or expiry, session token, account/provider details, or any role,
|
||
permission, membership, or volunteer data.
|
||
|
||
## 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
|
||
access, no module-management access, no staff/volunteer-management access, and no
|
||
media/event/form/roster/people-management permission — Core has no authorization schema in this
|
||
slice to grant any of that, and no code path in this repository inspects how many accounts already
|
||
exist or treats an empty database as special. Baseline authenticated status is what will eventually
|
||
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
|
||
saved content, preferences) — none of those features are implemented in this PR.
|
||
|
||
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.
|
||
|
||
`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. 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.
|
||
|
||
## 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 shared or edge limiter, reserved for pass 5.
|
||
|
||
## Request body limits
|
||
|
||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||
declared-length-free streamed body without ever buffering the whole payload first):
|
||
|
||
| 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 (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||
|
||
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. 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. Large media transfer is explicitly out of scope for ordinary module
|
||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||
|
||
## Node request and header timeouts
|
||
|
||
`apps/api/src/server.ts` configures the underlying Node HTTP server (via `@hono/node-server`'s
|
||
`serverOptions`, passed straight through to `http.createServer`) with finite
|
||
`headersTimeout` (20s), `requestTimeout` (30s), and `keepAliveTimeout` (5s, Node's own default,
|
||
made explicit). Node's own documented behavior responds `408` and closes the connection without
|
||
ever invoking a request listener when either timeout is exceeded, so a timed-out connection cannot
|
||
invoke a handler or receive a second response by construction.
|
||
|
||
Two non-obvious details, both found only by reproducing them directly against the pinned Node 24
|
||
runtime through a real socket:
|
||
|
||
1. **Detection latency.** Node's `http.Server` only *checks* for a timeout violation when its
|
||
periodic `connectionsCheckingInterval` fires, and that option's own default is 30 seconds —
|
||
leaving it unset would silently let a violating connection sit for up to a further 30 seconds
|
||
past its configured timeout. `connectionsCheckingIntervalMs` is therefore also set explicitly
|
||
(5s).
|
||
2. **The observable event is `'clientError'`, not `'timeout'`.** Node's general server `'timeout'`
|
||
event belongs to `server.setTimeout()`'s idle-socket timer, which this server never arms; it
|
||
does **not** fire for `headersTimeout`/`requestTimeout`. Those surface as `'clientError'` with
|
||
`err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. `apps/api/src/server.ts` therefore observes
|
||
`'clientError'`, and — because registering any `'clientError'` listener *suppresses* Node's own
|
||
default response entirely (`_http_server.js` only falls back when the emit returns `false`) —
|
||
that handler reproduces Node's default byte-for-byte rather than merely watching: `408 Request
|
||
Timeout` for a timeout, `431 Request Header Fields Too Large` for a header overflow, and `400
|
||
Bad Request` otherwise, each followed by destroying the socket, and each written only when the
|
||
socket is still writable and no response has already begun (so a handler that already responded
|
||
before an unread body timed out never receives a second, corrupting status line).
|
||
|
||
Exactly one fixed, sanitized operational line is logged per transport-level client failure — one
|
||
message for a timeout, one for any other malformed/oversized request. Neither ever contains the
|
||
request path, query, body, cookies, authorization value, client-address header, forwarding chain,
|
||
raw peer address, stack, or the caught error's own message (a parse failure's message is derived
|
||
from attacker-controlled input). This is proven at the real transport boundary by raw-socket
|
||
scenarios in `apps/api/tests/api-http.integration.ts` — a real slow-header connection, a real
|
||
slow-body connection, a malformed request line, and an oversized header block — each asserting the
|
||
exact response bytes, exactly one status line, socket closure, no handler invocation, and exactly
|
||
one sanitized log. See
|
||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) for the full
|
||
account.
|
||
|
||
## Module discovery, permission composition, and module runtime startup
|
||
|
||
`startApi()` composes production in one fixed order:
|
||
|
||
1. Discover every installed module's manifest exactly once and compose the one immutable platform
|
||
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
|
||
`apps/api/src/platform-permissions.ts`.
|
||
2. Construct the one shared PostgreSQL pool.
|
||
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
|
||
pool (see [Module database access](#module-database-access)).
|
||
4. Construct the Core identity runtime, authorization store, and evaluator.
|
||
5. Construct the host object-storage provider from validated configuration, if this installation has
|
||
one — building the client opens no connection, so a storage outage can never stop the platform
|
||
from booting (ADR 0030). Then read the stored module enablement configuration and the stored module
|
||
configuration, and resolve, over that exact same discovered inventory, which modules are
|
||
**eligible** to run and what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
|
||
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle) and
|
||
[Module configuration](#module-configuration)). The two reads are sequential rather than raced so
|
||
a broken deployment reports the same error code every time.
|
||
6. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — modules are
|
||
never rediscovered for this step — passing it the services object every module's `start`/`stop`
|
||
hook receives.
|
||
7. Create the Hono application, mounting the started module runtime's HTTP routes and realtime
|
||
channels, publishing UI for that same eligible set, and passing the identical scoped runtime
|
||
objects to module HTTP handlers.
|
||
8. Bind the HTTP server.
|
||
|
||
Discovery and permission composition happen before any pool, Better Auth instance, module
|
||
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
|
||
manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission
|
||
declaration, ...) fails startup outright, before there is anything to close; startup never falls
|
||
back to a Core-only registry. Core infrastructure exists before any executable module lifecycle
|
||
code runs, and every module route is published only after the *entire* module runtime has
|
||
registered and started successfully, so no request can ever reach a module before every module has
|
||
started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
|
||
for the permission-composition detail, and [Module development](module-development/README.md) for
|
||
the manifest shape that produces these declarations.
|
||
|
||
Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a
|
||
successful production startup does import each **eligible** module's compiled server entrypoint and
|
||
run its `register`/`start` hooks — this is the one place in the platform that deliberately executes
|
||
module code, and only after every other startup step already listed has succeeded. A module that is
|
||
disabled, or blocked by a disabled required dependency, is never imported at all.
|
||
|
||
Step 5 makes startup depend on a reachable, migrated database: if the enablement configuration
|
||
cannot be read, `startApi` **refuses to start** with Core's sanitized `MODULE_LIFECYCLE_READ_FAILED`
|
||
(and `MODULE_CONFIGURATION_READ_FAILED` if the enablement read succeeded and the configuration read
|
||
did not) rather than starting every discovered module. The platform cannot know what an administrator
|
||
decided, and starting a module they disabled is worse than not starting. Nothing is created or
|
||
repaired on that path.
|
||
|
||
## Module lifecycle
|
||
|
||
```text
|
||
GET /api/v1/module-lifecycle
|
||
PUT /api/v1/module-lifecycle/<module-id>/enabled { "enabled": boolean }
|
||
```
|
||
|
||
The platform's operational view of its installed modules
|
||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): which are enabled, what
|
||
this process actually did with each of them, and whether the two currently disagree.
|
||
|
||
It is a **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` is
|
||
(ADR 0025): `enabled` is a legal module route segment, so a module named `announcements` could
|
||
otherwise register `PUT /enabled` and collide with the platform at exactly
|
||
`/api/v1/modules/announcements/enabled`.
|
||
|
||
```json
|
||
{
|
||
"modules": [
|
||
{
|
||
"id": "storage",
|
||
"version": "1.0.0",
|
||
"configured": { "enabled": false, "explicit": true },
|
||
"runtime": { "state": "disabled" },
|
||
"restartRequired": false
|
||
},
|
||
{
|
||
"id": "announcements",
|
||
"version": "1.0.0",
|
||
"configured": { "enabled": true, "explicit": false },
|
||
"runtime": {
|
||
"state": "blocked",
|
||
"reason": "required_dependency_disabled",
|
||
"dependency": { "kind": "module", "moduleId": "storage" }
|
||
},
|
||
"restartRequired": false
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Two axes, deliberately not one status word
|
||
|
||
`configured` is the decision that survives restarts. `explicit: false` means nobody has decided and
|
||
the platform default (**enabled**) applies. `runtime` is what *this process* did:
|
||
|
||
| state | meaning |
|
||
| ---------- | ----------------------------------------------------------------------------- |
|
||
| `running` | the module was eligible and its `start` hook completed in this process |
|
||
| `disabled` | configuration says it must not run, so it was never imported |
|
||
| `blocked` | it should run, but a **required** dependency cannot — see `reason`/`dependency` |
|
||
|
||
`running` says the `start` hook completed. It says **nothing** about whether every external resource
|
||
that module uses is reachable right now; Ekklesia has no runtime health-probe contract, and this
|
||
state must not be read as one. There is deliberately no `failed` state: module startup is
|
||
all-or-nothing, so a module that fails to start aborts API startup and no live process could ever
|
||
report it.
|
||
|
||
`reason` is one of `required_dependency_disabled` (the named dependency was turned off) or
|
||
`required_dependency_blocked` (the named dependency is itself blocked). Following the chain always
|
||
terminates at a `..._disabled` entry. An **optional** dependency never blocks anything.
|
||
|
||
### Changing enablement requires a restart, and the response says so
|
||
|
||
There is no hot module installation or removal, so `PUT` writes a decision and changes nothing about
|
||
the running process:
|
||
|
||
```json
|
||
{ "id": "announcements", "version": "1.0.0",
|
||
"configured": { "enabled": false, "explicit": true },
|
||
"runtime": { "state": "running" },
|
||
"restartRequired": true }
|
||
```
|
||
|
||
Reporting `runtime: disabled` there would be a lie about a process still serving that module's
|
||
routes. `restartRequired` compares what the current configuration *would* start against what the
|
||
runtime reports it *did* start, so it is symmetric and transitive: disabling a module also marks its
|
||
dependents, because they really will stop on the next boot.
|
||
|
||
A module whose enablement changed but whose process has not restarted stays fully exposed — routes,
|
||
artifact, inventory, and navigation. It really is still mounted; hiding it now would break a viewer
|
||
mid-session for a change that has not taken effect.
|
||
|
||
### What a disabled module does not have
|
||
|
||
Never imported ⇒ no `register`, `start`, or `stop` hook runs; no HTTP routes are mounted (an
|
||
unmounted path gets the platform's ordinary `404`, with no per-request enablement check anywhere);
|
||
no capabilities are registered; `GET /api/v1/module-ui/<id>/<version>` returns the same `404` an
|
||
uninstalled module does; and it is absent from both `GET /api/v1/module-ui` and
|
||
`GET /api/v1/dashboard/navigation`.
|
||
|
||
Its **migrations are still discovered and applied**. Deployed schema ownership is not runtime
|
||
enabled state: a disabled module's tables must already exist when it is enabled, or enabling it would
|
||
require a schema change in the middle of a restart. Its **permissions also stay registered**, so
|
||
grants survive an enable/disable cycle and can be issued ahead of enabling a module; nothing enforces
|
||
them, because it mounts no routes.
|
||
|
||
### Authority
|
||
|
||
Both verbs require `core.modules.lifecycle.manage` (baseline `restricted`). The read is gated too,
|
||
because this resource is the only place a disabled module, a blocked module, and the
|
||
required-dependency edges between them are visible at all — `core.modules.ui.read` deliberately
|
||
covers only what is mountable. Mutations additionally require an exact trusted `Origin` and a bounded
|
||
body (`CORE_MUTATION_MAX_BODY_BYTES`) before authorization work begins.
|
||
|
||
The body is closed: any key beyond `enabled` invalidates the whole request rather than being dropped.
|
||
A `PUT` naming a module this process did not discover is `404 {"code":"MODULE_NOT_INSTALLED"}` —
|
||
enablement is operational configuration, not an unbounded key-value store. Existing records for
|
||
modules that are no longer deployed are retained and ignored, so redeploying restores the decision.
|
||
|
||
No response carries a filesystem path, package name, manifest path, entrypoint, caught error, or
|
||
stack.
|
||
|
||
### Recovering from a module that breaks startup
|
||
|
||
Startup is all-or-nothing, so a module whose `start` hook throws takes the API down — including this
|
||
API. The out-of-band path is a Core CLI, in the same shape as `pnpm authorization:bootstrap-owner`
|
||
(explicit, never HTTP-exposed):
|
||
|
||
```bash
|
||
pnpm modules:disable -- --module announcements
|
||
pnpm modules:enable -- --module announcements
|
||
```
|
||
|
||
It reads `DATABASE_URL`, runs the same manifest-only discovery the API does (so it refuses a module
|
||
this deployment cannot see), writes the decision, and starts nothing. Restart the API to apply it.
|
||
|
||
## Module configuration
|
||
|
||
```text
|
||
GET /api/v1/module-configuration/<module-id>
|
||
PUT /api/v1/module-configuration/<module-id> { "values": { "slideSeconds": 12 } }
|
||
```
|
||
|
||
What one installed module declares it needs configured, what an operator has stored, and whether the
|
||
running process is still using it
|
||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)).
|
||
|
||
A **sibling** of `/api/v1/modules/`, never a child, for the same reason `/api/v1/module-ui` and
|
||
`/api/v1/module-lifecycle` are: `config` and `configuration` are legal module route segments, so a
|
||
module could otherwise collide with the platform at `/api/v1/modules/<id>/config`.
|
||
|
||
```json
|
||
{
|
||
"moduleId": "announcements",
|
||
"version": "1.0.0",
|
||
"keys": [
|
||
{ "key": "slideSeconds", "type": "integer", "required": false, "default": 7,
|
||
"description": "How long each slide is shown.", "configured": false },
|
||
{ "key": "bucket", "type": "string", "required": true, "value": "slides", "configured": true },
|
||
{ "key": "storageCredential", "type": "secret", "required": true, "configured": true }
|
||
],
|
||
"unknownKeys": [],
|
||
"restartRequired": false
|
||
}
|
||
```
|
||
|
||
### A secret's value is absent, not redacted
|
||
|
||
Note what the `storageCredential` entry above does **not** have: a `value` field. Not `null`, not
|
||
`"***"` — absent. Ekklesia never persists secret material, so the store this route reads from
|
||
contains none, and there is no code path that could serve one. `configured` answers the only question
|
||
an operator needs from here: does the deployment supply it?
|
||
|
||
### The declared types
|
||
|
||
| type | notes |
|
||
| --- | --- |
|
||
| `string` | optional `minLength` / `maxLength` |
|
||
| `integer` | optional `minimum` / `maximum`; submitted as a JSON number, never a string |
|
||
| `boolean` | submitted as a JSON boolean |
|
||
| `enum` | closed `values` list |
|
||
| `url` | absolute `http:`/`https:` only |
|
||
| `secret` | deployment-supplied; never stored, never returned, never defaulted |
|
||
|
||
No nesting, no arrays, no regular expressions. This is a validation vocabulary, not a form-builder
|
||
DSL; a module needing structured data owns a table for it.
|
||
|
||
### Writing is a whole-document replace, with closed validation
|
||
|
||
A key the module does not declare is `400 CONFIGURATION_KEY_UNDECLARED`. A wrong JSON type or a value
|
||
failing the module's own declared constraints is `400 CONFIGURATION_VALUE_INVALID`. A `secret` key is
|
||
`400 CONFIGURATION_SECRET_NOT_WRITABLE` — there is no supported way to submit secret material to this
|
||
platform. Nothing is coerced: `{"slideSeconds": "7"}` is a `400`, not a silent `7`.
|
||
|
||
Constraint checking runs through **the same resolver startup uses**, over the exact document about to
|
||
be stored, so the API and startup can never disagree about whether a value is valid.
|
||
|
||
An *incomplete* document is accepted deliberately — an operator may save partial work, and the module
|
||
is reported `blocked · configuration_missing` until they finish. An *invalid* one never is.
|
||
|
||
A key omitted from the submission is deleted. Stored keys the module's current manifest no longer
|
||
declares are **retained and ignored**, and listed as `unknownKeys`, so a downgrade or a rename does
|
||
not destroy an operator's value.
|
||
|
||
### Changes take effect at the next start
|
||
|
||
Modules receive a configuration snapshot resolved once at startup, so a `PUT` changes nothing about
|
||
the running process and the response reports `restartRequired` rather than pretending otherwise.
|
||
Nothing is hot-reloaded. A setting that genuinely must change live is the module's own domain state,
|
||
stored in the module's own tables and exposed through its own route — not platform configuration.
|
||
|
||
### Missing or invalid configuration blocks the module
|
||
|
||
An enabled module whose required configuration cannot be satisfied is never imported. It appears in
|
||
`GET /api/v1/module-lifecycle` as `blocked` with a structured reason naming the offending **key**:
|
||
|
||
```json
|
||
{ "runtime": { "state": "blocked", "reason": "configuration_missing", "configurationKey": "bucket" } }
|
||
```
|
||
|
||
`configuration_missing` (set a value), `configuration_invalid` (correct the value), and
|
||
`secret_missing` (set it in the deployment environment) are separate reasons because each has a
|
||
different fix. A blocked module blocks its required dependents exactly like a disabled one does.
|
||
|
||
### Secrets come from the deployment
|
||
|
||
A module declares a `secret` key; the host maps it to a deployment-supplied value. In an Ekklesia
|
||
deployment that mapping is an environment variable:
|
||
|
||
```text
|
||
EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>
|
||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL
|
||
```
|
||
|
||
The module never sees that name — it calls `config.getSecret("storageCredential")` — which is what
|
||
lets a future standalone host supply the same declared secret from somewhere else entirely.
|
||
|
||
### Authority
|
||
|
||
Both verbs require `core.modules.configuration.manage` (baseline `restricted`), deliberately separate
|
||
from `core.modules.lifecycle.manage`: deciding whether a module runs and deciding how it behaves are
|
||
different authorities. Mutations additionally require an exact trusted `Origin` and a bounded body.
|
||
|
||
No response carries a filesystem path, package name, manifest path, caught error, stack, or secret
|
||
value.
|
||
|
||
## Module object storage
|
||
|
||
Ekklesia can provide modules with logical object storage backed by any S3-compatible service
|
||
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)). There is **no
|
||
HTTP API for it**: storage is a runtime capability modules use, not a resource the platform exposes,
|
||
and no route returns a provider endpoint or credential.
|
||
|
||
### Host configuration
|
||
|
||
| variable | meaning |
|
||
| --- | --- |
|
||
| `S3_ENDPOINT` | Absolute `http`/`https` URL. **Absent means this installation has no object storage.** |
|
||
| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Required once an endpoint is set |
|
||
| `S3_REGION` | Defaults to `us-east-1` |
|
||
| `S3_FORCE_PATH_STYLE` | Defaults to `true`; self-hosted services need it |
|
||
| `S3_BUCKET_PREFIX` | Defaults to `ekklesia`; namespaces this installation on a shared service |
|
||
| `S3_DELIVERY_ENDPOINT` | The origin a **browser** reaches storage on. Defaults to `S3_ENDPOINT`; set it only when that is not browser-reachable |
|
||
|
||
Production requires `https`, exactly as the database transport requires verified TLS. An endpoint
|
||
embedding credentials is rejected rather than stripped. These values reach Core's provider and
|
||
nothing else — never a module, module UI, browser bundle, diagnostic, or API response.
|
||
|
||
The repository's development Compose stack already runs SeaweedFS for this, and `.env.example`
|
||
already carries matching values.
|
||
|
||
`S3_DELIVERY_ENDPOINT` exists because an S3-compatible signature covers the request's `Host`, so a
|
||
delivery URL cannot be signed for one origin and rewritten to another — the browser-facing origin has
|
||
to be known when the URL is signed. Leave it unset whenever `S3_ENDPOINT` is itself reachable from a
|
||
browser, which is true of the development stack (Compose publishes storage on loopback) and of any
|
||
deployment where Core and browsers use the same name. Set it when Core reaches storage privately —
|
||
`http://object-storage:8333` — and browsers reach it publicly. The scheme is *not* signed, so a
|
||
reverse proxy may terminate TLS and forward plaintext, **provided it passes the original `Host`
|
||
header through unchanged**; a proxy that rewrites `Host` to its backend makes every delivery URL fail
|
||
with `403`.
|
||
|
||
### Logical buckets, and what modules get
|
||
|
||
A module declares logical buckets in its manifest (`storage.buckets`) and receives a scoped facade at
|
||
`context.storage` on its runtime and HTTP handler contexts. It names a logical bucket; the host maps
|
||
it to a physical one deterministically and never exposes that name.
|
||
|
||
An installation with **no** provider is a complete deployment: modules declaring no bucket run
|
||
normally. A module that *does* declare one is reported `blocked · storage_unavailable` on
|
||
`GET /api/v1/module-lifecycle`, naming the logical bucket:
|
||
|
||
```json
|
||
{ "runtime": { "state": "blocked", "reason": "storage_unavailable",
|
||
"storageBucketId": "announcement-assets" } }
|
||
```
|
||
|
||
Its required dependents block behind it through the ordinary dependency mechanism.
|
||
|
||
### Binding availability is not provider health
|
||
|
||
Eligibility asks whether storage is **configured**, never whether the service answered. A configured
|
||
provider that is temporarily unreachable is an operational error the module catches and retries —
|
||
`running` still means the `start` hook completed, and no lifecycle state changes because a storage
|
||
call timed out. Buckets are created lazily on first use rather than at startup, so a storage outage
|
||
never prevents the platform from booting, and nothing ever deletes a bucket.
|
||
|
||
### Serving objects to browsers
|
||
|
||
A module can hand a browser a temporary URL and let it fetch the object from storage directly
|
||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)):
|
||
|
||
```text
|
||
browser → GET /api/v1/modules/<id>/slides → module route → { key, url, expiresAt }
|
||
browser → GET <url> ─────────────────────────────────────→ storage service
|
||
```
|
||
|
||
The module authorizes the request in its own route, asks its logical bucket for a URL, and returns
|
||
it. Ekklesia is not in the data path, so byte ranges, caching, and revalidation are the storage
|
||
service's job — a `Range` request against a delivery URL is answered directly with `206`, which is
|
||
what makes media seeking work without a range proxy in Core.
|
||
|
||
```ts
|
||
const assets = context.storage.bucket("announcement-assets");
|
||
const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");
|
||
```
|
||
|
||
A delivery URL is a **bearer capability**: whoever holds it can read that one object until it
|
||
expires. Deciding who may have one is the module's authorization decision, made before it asks;
|
||
the storage contract only bounds how far the URL reaches. Treat a complete URL as a secret — never
|
||
log one (see [Security](security.md#object-storage-is-scoped-by-binding-and-modules-never-hold-credentials)).
|
||
|
||
Streaming through the module's own route with `context.storage.get(...)` remains available and is
|
||
still the right choice when the response must be computed, transformed, or authorized per byte.
|
||
There are no signed **write** URLs: uploads go through a module route, where the module can validate
|
||
what it is accepting.
|
||
|
||
## Module HTTP routes
|
||
|
||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||
[Module development](module-development/README.md#http-routes) for the module-authoring contract)
|
||
are mounted under `/api/v1/modules/<module-id>`, in the module runtime's published order, by
|
||
`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler.
|
||
|
||
- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own
|
||
routes use, with the route's own declared permission id as the only permission ever consulted —
|
||
its trusted baseline always comes from the composed platform registry, never from the route or
|
||
the request. A `public`, `authenticated`, and `restricted` module permission are enforced through
|
||
the exact same path; a public-baseline route still resolves and receives an authorized principal.
|
||
- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an
|
||
exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before
|
||
authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require
|
||
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
|
||
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
|
||
(same permission check, response body stripped); a module cannot separately author `HEAD`.
|
||
- **Body limit.** A route's own `maxBodyBytes` (see [Module development](module-development/README.md#http-routes)
|
||
and [Request body limits](#request-body-limits)) is enforced immediately after the trusted-origin
|
||
check and before permission evaluation — `0` means no body is accepted at all. An oversized
|
||
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
|
||
authorization, the handler, or the module database.
|
||
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and body
|
||
all behave normally, and the full query string remains available) plus a frozen context of exactly
|
||
`moduleId`, the authorized `EkklesiaPrincipal`, frozen Hono-decoded path parameters (an empty
|
||
frozen object for a route with none), `services`, and the module's scoped configuration, storage,
|
||
and realtime publisher. It never receives a Hono `Context`, authorization/grant stores, jobs, or
|
||
another module's scoped objects. Core does not serialize configuration or secrets into responses.
|
||
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
|
||
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
|
||
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
|
||
handler deliberately returns — including a 4xx/5xx one — passes through unchanged.
|
||
- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like
|
||
every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now
|
||
additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its
|
||
own response is stripped before the response leaves the process. Every module response is forced
|
||
to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's
|
||
own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a
|
||
dedicated cache-policy contract exists, since even a public-baseline handler receives an
|
||
authorized principal and may personalize its response.
|
||
- **Static snapshot.** Routes are part of the same static startup snapshot as the permission
|
||
registry: installing, removing, or editing a module's routes takes effect only on the next API
|
||
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
|
||
|
||
## Module realtime
|
||
|
||
An eligible module may declare a module-local state channel during `register`. Ekklesia mounts one
|
||
exact route for each successfully started declaration:
|
||
|
||
```text
|
||
GET /api/v1/module-realtime/<module-id>/<channel>
|
||
```
|
||
|
||
The path is host-private. Module UI receives the module's server-produced realtime base in its mount
|
||
descriptor and calls `context.realtime.subscribe("state", handlers)`; it never builds this URL,
|
||
chooses a module id, supplies a permission, or sees an EventSource. Browser commands remain ordinary
|
||
module HTTP requests, retaining their body, origin, authorization, and response semantics.
|
||
|
||
The response is a Server-Sent Events stream. It begins with exactly one authoritative `snapshot`
|
||
event and then carries `update` events:
|
||
|
||
```text
|
||
event: snapshot
|
||
data: {"sequence":1,"payload":{"mode":"on","currentSlide":0}}
|
||
|
||
event: update
|
||
data: {"sequence":2,"payload":{"mode":"on","currentSlide":1}}
|
||
```
|
||
|
||
Core atomically captures the latest state while installing the subscriber, so there is no separate
|
||
GET-then-subscribe gap. Sequence is monotonically increasing per channel for this process only; there
|
||
is no `id` field, `Last-Event-ID` replay, durable history, or exactly-once promise. A reconnect is a
|
||
new subscription and begins with the latest snapshot. Slow clients retain only the newest pending
|
||
update, so sequence gaps are expected and intermediate states may be coalesced.
|
||
|
||
- **Authentication and authorization.** The SSE GET uses the existing session cookie and the same
|
||
principal resolver, permission registry, and evaluator as module HTTP. The channel's declaration
|
||
fixes the permission before any request exists. Permission evaluation completes before a Core
|
||
subscriber is allocated. There is no query-string token or second realtime auth system.
|
||
- **Reauthorization.** A stream has a host-owned maximum age of five minutes. The host closes it at
|
||
that boundary; native EventSource reconnects and the ordinary cookie and permission checks run
|
||
again. Grant revocation, grant expiry, and session changes therefore do not leave an accepted
|
||
stream authorized forever.
|
||
- **Bounds.** Core accepts at most 16 channels per module, 100 subscribers per channel, and 64 KiB
|
||
of serialized JSON per publication. Publishing never awaits a browser. One latest snapshot and
|
||
one pending update per subscriber are the only state retained.
|
||
- **Transport behavior.** Responses use `text/event-stream; charset=utf-8`, `Cache-Control:
|
||
no-store`, `Vary: Origin, Cookie`, `X-Accel-Buffering: no`, and a 15-second comment keepalive.
|
||
Keepalives consume no module sequence and never reach module UI. A reverse proxy must forward
|
||
streaming bodies incrementally and permit that idle interval. No proxy product is part of the
|
||
module contract.
|
||
- **Methods and errors.** Hono normally lets `HEAD` reuse GET handlers; this route explicitly returns
|
||
`405` before allocating a stream because discarding an infinite body could leak a subscriber.
|
||
Subscriber exhaustion is the fixed `503 {"code":"REALTIME_SUBSCRIBER_LIMIT"}`; other stopped or
|
||
unavailable runtime states are `503 {"code":"REALTIME_UNAVAILABLE"}`. Raw errors, paths, URLs,
|
||
payloads, permissions, secrets, and stacks are never reflected.
|
||
- **Lifecycle.** Disabled and blocked modules have no route because their entrypoint never runs and
|
||
their channel is never mounted. Shutdown first refuses and closes realtime subscriptions so they
|
||
cannot hold HTTP drain open. Ordinary in-flight module HTTP handlers may finish and publish during
|
||
that drain. Core then stops modules in reverse dependency order, deactivating each module's scoped
|
||
publisher immediately before its own stop hook; a captured publisher fails predictably afterward.
|
||
|
||
Realtime carries opaque module state. If a module includes a direct object-delivery URL, the exact
|
||
serialized string passes through unchanged: Core neither re-signs it per subscriber nor extends its
|
||
expiry. Image, video, and audio bytes remain object-storage traffic, never realtime payloads. See
|
||
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
|
||
|
||
## Dashboard navigation
|
||
|
||
```text
|
||
GET /api/v1/dashboard/navigation
|
||
PUT /api/v1/dashboard/navigation/groups/<group-id> { "label": "Media", "order": 10 }
|
||
DELETE /api/v1/dashboard/navigation/groups/<group-id>
|
||
PUT /api/v1/dashboard/navigation/placements/<module-id> { "groupId": "media"|null, "order": 10 }
|
||
```
|
||
|
||
How this installation presents its mountable modules
|
||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)).
|
||
|
||
```json
|
||
{
|
||
"groups": [
|
||
{
|
||
"id": "media",
|
||
"label": "Media",
|
||
"order": 10,
|
||
"modules": [
|
||
{
|
||
"name": "Livestream",
|
||
"module": {
|
||
"id": "livestream",
|
||
"version": "1.0.0",
|
||
"ui": { "url": "/api/v1/module-ui/livestream/1.0.0" },
|
||
"api": { "baseUrl": "/api/v1/modules/livestream" },
|
||
"realtime": { "baseUrl": "/api/v1/module-realtime/livestream" },
|
||
"permissions": ["livestream.streams.read"]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"ungrouped": []
|
||
}
|
||
```
|
||
|
||
### Navigation versus inventory
|
||
|
||
They are deliberately separate, and this route is layered **on top of** the inventory rather than
|
||
merged into it:
|
||
|
||
- `GET /api/v1/module-ui` answers *what may be mounted*, host-neutrally. A standalone host consumes
|
||
it and never learns Ekklesia has folders. It carries no display name, group, or order.
|
||
- `GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and
|
||
**embeds** each generic descriptor untouched under `module`.
|
||
|
||
Both compose that descriptor through one shared function, so they cannot disagree about what exists
|
||
or about any URL.
|
||
|
||
### Grouping is presentation, never module structure
|
||
|
||
A group is a label on a rendering bucket. It is **not a module**: it has no manifest, version,
|
||
artifact, API, permission, or lifecycle, and nothing depends on it. Placing Livestream and Video in
|
||
one folder creates no relationship between them, and moving a module changes a single presentation
|
||
row — never its artifact, API root, permissions, dependencies, or startup order. Real technical
|
||
dependencies remain the module dependency contract's job and are the only relationship modules have.
|
||
|
||
### Resolution rules
|
||
|
||
- A module with **no placement is ungrouped and visible** — installing it is enough, with no
|
||
initialization write and no manual database edit.
|
||
- A stored placement for a module that is **not installed renders nothing and is retained**, so
|
||
uninstall-and-reinstall preserves an arrangement.
|
||
- **Empty groups are kept** (hiding one would look like deletion), and a placement naming a missing
|
||
group renders as ungrouped rather than disappearing.
|
||
- Deleting a group **ungroups its modules** through the schema's `on delete set null`; a dangling
|
||
reference is impossible.
|
||
- Ordering is total: groups by `(order, id)`, modules by `(configured, order, id)` — placed modules
|
||
in the administrator's order, then never-configured ones by id, with the identifier always the
|
||
final tie-break, so colliding order values are still deterministic.
|
||
|
||
### Authority
|
||
|
||
Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this
|
||
response contains it and serves the same audience. Organizing requires
|
||
`core.dashboard.navigation.manage` (baseline `restricted`), a **platform** permission and never a
|
||
module's, plus an exact trusted `Origin` and a bounded body. Holding it grants no module authority:
|
||
every module route stays enforced against its own declared permission, and no arrangement of folders
|
||
changes an authorization decision.
|
||
|
||
Group ids are opaque, stable, lowercase kebab-case keys, never displayed and never re-derived from a
|
||
label — so renaming a group cannot re-organize the Dashboard. Bodies are closed: any key beyond the
|
||
declared ones makes the whole request invalid rather than being silently dropped. Configuration is
|
||
platform-global; Ekklesia has no organization or tenant model to scope it to yet.
|
||
|
||
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||
|
||
## Module UI inventory
|
||
|
||
```text
|
||
GET /api/v1/module-ui
|
||
```
|
||
|
||
The one query a browser host makes before it can mount anything
|
||
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). It is the collection
|
||
root of the same resource the artifact route below serves: one prefix, two representations.
|
||
|
||
```json
|
||
{
|
||
"modules": [
|
||
{
|
||
"id": "example",
|
||
"version": "0.1.0",
|
||
"ui": { "url": "/api/v1/module-ui/example/0.1.0" },
|
||
"api": { "baseUrl": "/api/v1/modules/example" },
|
||
"realtime": { "baseUrl": "/api/v1/module-realtime/example" },
|
||
"permissions": ["example.records.read"]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
- **Six fields, each because a `ModuleUiHostContext` needs it.** No filesystem path, package root,
|
||
package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And
|
||
no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are navigation metadata,
|
||
and [ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard
|
||
grouping an administrator's presentation configuration rather than a module declaration.
|
||
- **All three URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl`, `api.baseUrl`
|
||
from `moduleApiBaseUrl`, and `realtime.baseUrl` from `moduleRealtimeBaseUrl`, so each URL shape is
|
||
constructed in exactly one place in the repository. A browser never rebuilds any platform module
|
||
prefix, which lets one change without touching host or module code.
|
||
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
|
||
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
|
||
Order is the registry's own deterministic discovery order.
|
||
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
|
||
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
|
||
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
|
||
this endpoint *is* that enumeration. `authenticated` rather than `restricted` because a shell that
|
||
rendered nothing until an operator granted every user a permission would be unusable.
|
||
- **`permissions` is the subset of that module's own declared permissions the caller holds**,
|
||
evaluated server-side by Core's one authorization evaluator — so baselines, the platform-owner
|
||
bypass, grant expiry, and deny-by-default behave exactly as on a real request. Granted ids only,
|
||
scoped to the module: no module learns the viewer's authorization state anywhere else in the
|
||
platform. **These are presentation hints, never authority**; every module route stays enforced by
|
||
`requirePermission` regardless of what a browser was told.
|
||
- **Failures.** Anonymous or unpermitted callers get `403 {"code":"AUTHORIZATION_DENIED"}` from the
|
||
shared middleware. An authorization-evaluation outage is `503
|
||
{"code":"AUTHORIZATION_EVALUATION_FAILED"}` rather than an empty hint set, because a hint that
|
||
silently read "denied" during an outage would blank the UI and look like a permissions problem.
|
||
- **Per-principal**, so `Cache-Control: no-store` and `Vary: Origin, Cookie`, exactly like
|
||
`/api/v1/principal`. `GET` only; no other method is routed.
|
||
|
||
## Module UI asset publication
|
||
|
||
A module may declare `entrypoints.ui`, a compiled browser ESM artifact
|
||
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md),
|
||
[ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). Core's discovery resolves it
|
||
to an absolute path on the server's filesystem; a browser needs a URL. This route is the bridge
|
||
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)).
|
||
|
||
```text
|
||
GET /api/v1/module-ui/<module-id>/<version>
|
||
```
|
||
|
||
- **Identity, not paths.** `:moduleId` and `:version` are lookup keys handed to a publication
|
||
registry Core builds from the discovered, started module inventory. Only an exact match on **both**
|
||
causes the already-canonical artifact path discovery validated to be read. No request value is ever
|
||
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
|
||
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
|
||
maps are unreachable by construction. **An installed module directory is never a static file
|
||
root**, and only the single artifact its manifest declares is ever served.
|
||
- **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`.
|
||
|
||
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that
|
||
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard
|
||
shell, no navigation, and no code that calls this route outside its own tests.
|
||
|
||
## 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 — through one shared environment reader
|
||
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable
|
||
constructors. `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).
|
||
|
||
## 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 signup → sign-in → principal → sign-out transcript
|
||
|
||
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
|
||
database, configured as **development** with public sign-up explicitly enabled — public sign-up is
|
||
disabled by default and cannot be enabled in production at all (see
|
||
[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and
|
||
generated ids below are sanitized/truncated examples, not real credentials.
|
||
|
||
```text
|
||
POST /api/auth/sign-up/email
|
||
{"email":"[email protected]","name":"Alice","password":"<redacted>"}
|
||
→ 200
|
||
{"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"[email protected]", ...}}
|
||
|
||
POST /api/auth/sign-in/email
|
||
{"email":"[email protected]","password":"<redacted>"}
|
||
→ 200
|
||
Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure
|
||
{"redirect":false,"token":"<redacted>","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted>
|
||
→ 200
|
||
Cache-Control: no-store
|
||
{"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"}
|
||
|
||
POST /api/auth/sign-out
|
||
Cookie: better-auth.session_token=<redacted>
|
||
Origin: https://dashboard.example.test
|
||
→ 200
|
||
Set-Cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
|
||
{"success":true}
|
||
|
||
GET /api/v1/principal
|
||
Cookie: better-auth.session_token=<redacted, now revoked>
|
||
→ 200
|
||
{"kind":"anonymous"}
|
||
```
|
||
|
||
## What's next
|
||
|
||
See the repository root [README](../../README.md#current-status) and
|
||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||
for what has landed across every phase and what remains before Surface work begins. This document
|
||
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
|