Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
59 KiB
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 for the
full architectural decision and Identity 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.
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.
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. 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 and
ADR 0019.
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). 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
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). |
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. |
EKKLESIA_CLIENT_ADDRESS_MODE |
Optional. "direct" (default when unset) or "trusted-proxy". See 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 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. |
/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. |
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 API lives, and the viewer's granted permissions for it. Requires core.modules.ui.read (baseline authenticated). See 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. |
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. |
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. |
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):
{ "kind": "anonymous" }
Authenticated:
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
Infrastructure failure (the identity session store was not usable while resolving the principal — never silently reported as anonymous):
{ "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.
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.
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 — 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
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
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:
logger. Better Auth's own default (level: "warn", plainconsole.*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-controlledOriginheader 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.") viaconsole.error, and only for error-level events — nothing is ever emitted for warn/info/debug.onAPIError: { throw: true }. Discovered while testing (1) above: the pinned release's own HTTP transport dependency,better-call's router, has an unconditionalconsole.error("# SERVER_ERROR: ", error)fallback for any thrown value it does not recognize as one of Better Auth's ownAPIErrors — this fires before Better Auth'sloggeroption is ever consulted, so nologgerconfiguration can reach it.onAPIError.throw: truecloses that gap: Better Auth's own deliberateAPIErrors (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 ofauth.handler()instead of being caught and printed raw internally bybetter-call, reachinghandleAuthRequest's own try/catch and becoming a sanitizedIdentityRuntimeErrorlike 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_MODEunset or"direct"). Trusts only the actual immediate socket peer. NoX-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, withEKKLESIA_CLIENT_ADDRESS_HEADERandEKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIESboth 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 sanitized503 {"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
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 for the module-route body
contract itself, and Module development 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:
- Detection latency. Node's
http.Serveronly checks for a timeout violation when its periodicconnectionsCheckingIntervalfires, 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.connectionsCheckingIntervalMsis therefore also set explicitly (5s). - The observable event is
'clientError', not'timeout'. Node's general server'timeout'event belongs toserver.setTimeout()'s idle-socket timer, which this server never arms; it does not fire forheadersTimeout/requestTimeout. Those surface as'clientError'witherr.code === "ERR_HTTP_REQUEST_TIMEOUT".apps/api/src/server.tstherefore observes'clientError', and — because registering any'clientError'listener suppresses Node's own default response entirely (_http_server.jsonly falls back when the emit returnsfalse) — that handler reproduces Node's default byte-for-byte rather than merely watching:408 Request Timeoutfor a timeout,431 Request Header Fields Too Largefor a header overflow, and400 Bad Requestotherwise, 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 for the full
account.
Module discovery, permission composition, and module runtime startup
startApi() composes production in one fixed order:
- 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. - Construct the one shared PostgreSQL pool.
- Construct the module database facade and the frozen
ModuleRuntimeServicesobject from that same pool (see Module database access). - Construct the Core identity runtime, authorization store, and evaluator.
- Start the module runtime from that exact same discovered inventory
(
startDiscoveredModuleRuntime) — modules are never rediscovered for this step — passing it the services object every module'sstart/stophook receives. - Create the Hono application, mounting the started module runtime's published routes and passing the identical services object to every module HTTP handler.
- 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
for the permission-composition detail, and Module development 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 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.
Module HTTP routes
An installed module's routes (declared via http.route(...) in its register hook — see
Module development 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
requirePermissionmiddleware 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. Apublic,authenticated, andrestrictedmodule permission are enforced through the exact same path; a public-baseline route still resolves and receives an authorized principal. - Trusted origin.
POST,PUT,PATCH, andDELETEmodule routes additionally require an exact trustedOrigin(see Origin, CORS, and cookies) before authorization ever runs, exactly like the existing grants-mutation routes.GETdoes not require one.OPTIONSremains entirely platform-owned: a module cannot register it, and a CORS preflight never reaches authorization or a handler.GETinherits Hono's own implicitHEADhandling (same permission check, response body stripped); a module cannot separately authorHEAD. - Body limit. A route's own
maxBodyBytes(see Module development and Request body limits) is enforced immediately after the trusted-origin check and before permission evaluation —0means no body is accepted at all. An oversized request receives the platform's stable413 {"code":"REQUEST_BODY_TOO_LARGE"}and never reaches authorization, the handler, or the module database. - Handler input. A handler receives the raw Fetch
Request(soRequest.url, headers, and body all behave normally, and the full query string remains available) plus a frozen context of exactlymoduleId, the authorizedEkklesiaPrincipal, frozen, Hono-decoded path parameters (an empty frozen object for a route with none), andservices— the same frozenModuleRuntimeServicesobject the module's ownstart/stophooks received (see Module database access) — never a HonoContext, the authorization store, storage, jobs, configuration, or secrets. - Handler failure. A handler that throws, rejects, or returns anything other than a
Responsebecomes a fixed500 {"code":"MODULE_HANDLER_FAILED"}withCache-Control: no-store— never the caught value, a stack, the route's path or permission id, or the module id. AResponsea 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, now additionally advertisingPUT/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 toCache-Control: no-storeand aVarythat always includesOriginandCookie(a handler's own legitimateVaryvalues 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"}.
Dashboard navigation
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).
{
"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" },
"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-uianswers 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/navigationanswers how this Dashboard presents those modules, and embeds each generic descriptor untouched undermodule.
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
GET /api/v1/module-ui
The one query a browser host makes before it can mount anything (ADR 0026). It is the collection root of the same resource the artifact route below serves: one prefix, two representations.
{
"modules": [
{
"id": "example",
"version": "0.1.0",
"ui": { "url": "/api/v1/module-ui/example/0.1.0" },
"api": { "baseUrl": "/api/v1/modules/example" },
"permissions": ["example.records.read"]
}
]
}
- Five fields, each because a
ModuleUiHostContextneeds it. No filesystem path, package root, package name, server/schema/migrations entrypoint, capability, dependency, or storage bucket. And noname,icon,description,category,folder, ororder: those are navigation metadata, and ADR 0021 keeps Dashboard grouping an administrator's presentation configuration rather than a module declaration. - Both URLs are server-produced.
ui.urlcomes frommoduleUiArtifactUrlandapi.baseUrlfrommoduleApiBaseUrl, so each URL shape is constructed in exactly one place in the repository. A browser never rebuilds/api/v1/module-ui/<id>/<version>or/api/v1/modules/<id>, which is what lets either 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, baselineauthenticated— 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.authenticatedrather thanrestrictedbecause a shell that rendered nothing until an operator granted every user a permission would be unusable. permissionsis 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 byrequirePermissionregardless of what a browser was told.- Failures. Anonymous or unpermitted callers get
403 {"code":"AUTHORIZATION_DENIED"}from the shared middleware. An authorization-evaluation outage is503 {"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-storeandVary: Origin, Cookie, exactly like/api/v1/principal.GETonly; no other method is routed.
Module UI asset publication
A module may declare entrypoints.ui, a compiled browser ESM artifact
(ADR 0021,
ADR 0023). 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).
GET /api/v1/module-ui/<module-id>/<version>
- Identity, not paths.
:moduleIdand:versionare 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 dynamicimport(), 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-identical404 {"code":"MODULE_UI_NOT_PUBLISHED"}, so the route is not an enumeration oracle. - Response.
Content-Type: text/javascript; charset=utf-8withX-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-cacheplus a strong SHA-256ETagover the bytes actually served, with RFC 9110If-None-Matchhandling (wildcard, weak comparison,304). Notimmutable: 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 for the module-authoring
contract; this section covers only how apps/api composes and wires it.
ModuleDatabase. A restricted, typed view overNodePgDatabase(packages/contracts/src/module-database.ts), exposing exactlyselect,selectDistinct,selectDistinctOn,insert,update,delete,execute, andtransaction, each preserving Drizzle's own table-aware argument and result inference. It exposes no$client, no internal session/dialect state, no schema-bound relationalquerybuilder, and no connection string, pool, or rawpgclient — not merely hidden by the TypeScript type, but physically absent from the constructed object at runtime (core/src/modules/database.ts'screateModuleDatabaseViewbuilds 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'screateApiDatabasewraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity, authorization, and nowmodule(aModuleDatabase).startApibuilds one frozenModuleRuntimeServicesobject (currently just{ database }) from that pool and passes the same object to every module'sstartandstophook 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 untilrequirePermission(and, for a state-changing method,requireTrustedOrigin) has already succeeded; a denied request, a rejected-Origin request, and anOPTIONSpreflight 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_FAILEDboundary described above — there is no separate, database-specific public error contract. A module that deliberately catches its own database failure may still return any validResponseof 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); nothing in module discovery, registration, start, or an HTTP request infers a schema or creates a table automatically. See ADR 0016 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 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.
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 stops the HTTP listener from accepting new connections and lets in-flight
requests finish; then stops the module runtime (so no module resource stops while a route handler
might still be in flight) — a module's stop hook may still use services.database here, since
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
and the module database facade all read from it — see
Authorization). 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. 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). Session tokens, cookie values, and generated ids below are sanitized/truncated examples, not real credentials.
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 and
platform architecture
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.