Files
Ekklesia/docs/development/api.md
T
ekklesiaandClaude Sonnet 5 dfaf324a65 feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 19:16:20 -05:00

25 KiB
Raw Blame History

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.

What this is, and is not

After this slice it is possible to start the API, publicly create an ordinary account, sign in, resolve a request to an Ekklesia principal, and sign out. 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 after this PR: there is no email delivery service, so an unverified email is trusted as entered and a forgotten password has no recovery path.

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.
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 An absolute http/https URL. https is required in production; loopback hosts are rejected in production.
EKKLESIA_IDENTITY_TRUSTED_ORIGINS Comma-separated list of absolute origins (no path/query/fragment, 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.
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. 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.

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 database could not be reached while resolving the principal — never silently reported as anonymous):

{ "code": "PRINCIPAL_RESOLUTION_FAILED" }

returned with HTTP 503.

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

Anyone may create an account (emailAndPassword.enabled: true, disableSignUp: false). 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. Trusted proxy headers are not configured in this slice — a deployment behind a reverse proxy is future work.

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 APIErrors — 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 APIErrors (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.

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 has two honest limitations: it is per-process (no protection across multiple API instances or restarts), and because no trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade abuse-control design.

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 Core identity runtime, authorization store, and evaluator.
  4. Start the module runtime from that exact same discovered inventory (startDiscoveredModuleRuntime) — modules are never rediscovered for this step.
  5. Create the Hono application, mounting the started module runtime's published routes.
  6. 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 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) 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.
  • 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, and frozen, Hono-decoded path parameters (an empty frozen object for a route with none) — never a Hono Context, a database connection, the authorization store, storage, jobs, configuration, or secrets.
  • 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, 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"}.

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. Both the identity and authorization domains read from this one pool through independent typed Drizzle views — see Authorization for why there is exactly one pool, not two.

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); then closes the one shared PostgreSQL pool (identity and authorization both 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. 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: identity/authorization construction failing (no module runtime was started yet); 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 existing rollback (already-started modules stop, in reverse order, 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. 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"}

Next phase

Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and reversible relationship-derived access itself. See Authorization for what this pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a Dashboard Surface, which still depends on an authorization model that is only now getting started.