25 KiB
0011: Identity HTTP transport and public enrollment policy
- Status: Accepted
- Date: 2026-07-21
Context
ADR 0010 established Core's identity schema, Core-owned identity migrations, and a Better Auth construction boundary, but deliberately stopped before exposing anything over HTTP: "no authentication endpoint or UI exists yet." Ekklesia now needs a real, publicly reachable way for a person to create an account and sign in, and a way for any Core/Surface/API code to resolve who (if anyone) is making a given request.
This is also the first PR to make a product policy decision about who may hold an Ekklesia identity and what that identity is worth. The project owner has been explicit: public registration is intentional, a registered account is not a church-membership record, and the platform must not let signup order or account creation itself grant any authority. Those decisions govern this ADR as much as the technical transport choices do.
Decision
Public registration is an intentional product policy, not a placeholder
Anyone may create an Ekklesia account through email/password sign-up. Registration creates only an
authentication identity — a row proving "this email and password combination can prove control
of a session" — not a church-person record, a membership record, or evidence of attending,
volunteering, or belonging to the church in any way. A future church-person/profile model, not
Better Auth's user table, is where that domain data will eventually live, linked only through the
opaque EkklesiaUserId (ADR 0010).
Every signup receives identical, lowest-privilege baseline status
Every account created through public registration receives the same thing: an authenticated
EkklesiaPrincipal carrying only its own opaque user ID. That status exists because being
authenticated is useful on its own — it is what will eventually let a signed-in person have
ownership-scoped personal features such as watch history, continue-watching state, saved content,
or preferences, all scoped to their own data. None of those features are implemented in this PR;
only the identity that will eventually carry them is. Baseline authenticated access is not an
administrative role, is not stored as a role/grant row (there is no role or grant system yet), and
is not distinguishable in storage from any other account beyond Better Auth's own required
user/account rows. It never grants Dashboard access, module-management access, staff or
volunteer-management access, or any media/event/form/roster/people-management permission — none of
that exists yet, and nothing in this PR creates a path toward an authenticated account acquiring it
implicitly.
Signup order is never an authorization input. No code path in this PR inspects how many users
already exist, branches on being the first account, or treats an empty database as special. The
first registered account and every account after it go through the exact same code path and
receive the exact same principal shape — proven directly by the test suite (see Testing). Platform
owner authority is not part of this schema at all: there is no isAdmin, isOwner, isStaff,
isVolunteer, isMember, role column, or admin plugin anywhere in this slice, because Core has no
authorization schema yet to grant such authority meaningfully. Platform-owner bootstrap is
deferred to Phase 4C, which must introduce an explicit, auditable, operator-invoked command that
grants an existing identity platform-owner authority — never an implicit consequence of being
first, and never a side effect of a public HTTP endpoint.
Future authorization is deferred, and its shape is already distinguished
This PR does not implement authorization. It is written expecting Phase 4C (or later) to distinguish, at minimum: public (unauthenticated) access; authenticated, self-scoped baseline access (this PR); relationship-derived access (for example, a person added to a volunteer roster automatically gaining narrowly scoped, reversible read access to that roster, which disappears the moment the qualifying relationship ends — no unrelated or permanent elevation); explicit grants or roles; and platform-owner authority. None of the last three exist yet. Relationship-derived access in particular belongs to a future roster slice, not this one — it is a fundamentally different mechanism (derived from a live domain relationship, not stored as a standing grant) and must not be approximated here with a role field.
Email/password is the only enabled provider, and why
emailAndPassword: { enabled: true, disableSignUp: false, autoSignIn: false } is the entire
authentication surface enabled in this slice. No social, OAuth/OIDC, passkey, magic-link, username,
OTP, or anonymous provider is enabled; no admin, organization, or two-factor plugin is enabled; no
custom user field, role field, or owner field is added to the schema. autoSignIn: false is
deliberate, not merely permitted: the pinned [email protected] release documents that setting
autoSignIn: false (like requireEmailVerification: true) activates its built-in email
enumeration protection — a duplicate sign-up returns the same 200 shape and a synthetic user
(token: null) instead of revealing that the email is already registered, and no second identity
row is created. This was verified directly against the pinned release, both by reading
packages/better-auth/src/api/routes/sign-up.ts and by exercising it against a live disposable
PostgreSQL database: a duplicate /sign-up/email call returns 200 with a fresh random id and
no second ekklesia_core_user row, while a first-time /sign-up/email call returns 200 with
token: null and no session cookie is set. A caller must call /sign-in/email explicitly
afterward to obtain a session. This is intentionally the only email-enumeration mitigation in
this slice; it is a Better Auth built-in, not new code.
Email verification and password recovery remain deferred because no email delivery service
exists in this repository yet — sendResetPassword and any verification-email sender are left
unconfigured. Consequently, public registration is not ready for a real public launch after
this PR: an account with a forgotten password has no recovery path, and an unverified email address
is trusted as entered. Both are explicitly out of scope here and are called out as follow-up work,
not silently accepted risk.
Password policy
Better Auth's default password-length policy (minPasswordLength: 8, maxPasswordLength: 128) is
kept unmodified; this ADR does not deliberately justify a different bound. Passwords are hashed
with Better Auth's own scrypt-based hashing (its documented default) — Core never substitutes
custom cryptography.
The Better Auth transport boundary hides Better Auth completely
core/src/identity/runtime.ts replaces Phase 4A's construction-proof-only factory (which returned
a discarded {constructed: true} marker) with createIdentityRuntime, now that a real HTTP
consumer exists. It returns a frozen EkklesiaIdentityRuntime exposing exactly two Fetch-standard
capabilities and nothing else:
handleAuthRequest(request: Request): Promise<Response>— delegates directly toauth.handler(request). Better Auth's own HTTP responses (including its 401s, 403s, and 422s) pass through completely unmodified; this is not rewritten into a second authentication protocol. Only a genuinely unexpected throw (not a normal Better Auth error response, which Better Auth itself already returns as aResponse) is converted into a sanitizedIdentityRuntimeError.resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal>— resolves the caller'sEkklesiaPrincipal, described below.
Neither method, nor anything else reachable from @ekklesia/core/identity, exposes auth.api,
Better Auth's inferred user/session types, Better Auth's database models, the Drizzle adapter, the
raw database, a session token, cookie contents, a password hash, or Better Auth configuration.
Core's public barrel (core/src/index.ts → core/src/identity/index.ts) exports only
createIdentityConfig/IdentityConfig (ADR 0010), createIdentityDrizzleDatabase (ADR 0010, for
composing the database in the executable entry point), and createIdentityRuntime/
EkklesiaIdentityRuntime/IdentityRuntimeError. The Drizzle table objects
(core/src/identity/schema.ts) remain internal to core/src/identity/, reachable only by Core's
own code and tests — nothing outside Core needs them, since createIdentityDrizzleDatabase already
bakes the schema in.
A resolved infrastructure failure must never look like "no session." This is a genuine tension
with the pinned Better Auth release: auth.api.getSession({headers}) is documented (and
source-verified) to catch every internal failure — including a downed database — and resolve to
null, exactly like a missing or expired session; it never throws. Left unmitigated, that would
mean a database outage during principal resolution would silently look identical to "nobody is
signed in," which is precisely the ambiguity this ADR is required to avoid. resolvePrincipal
therefore does not trust a null result unconditionally: when the incoming request carries no
Cookie header at all, Better Auth's own source never queries the database for that case (verified
in packages/better-auth/src/api/routes/session.ts), so null is unambiguous and is returned as
the anonymous principal immediately, at zero extra cost — this is the common case (most requests
carry no session cookie). Only when a Cookie header is present and getSession still returns
null — a genuinely ambiguous case, since it could mean an expired/malformed session or a
database that went unreachable partway through resolution — does resolvePrincipal run one small,
bounded (3-second) reachability probe (select 1) against the same injected database connection
before trusting the null. If the probe also fails, resolvePrincipal throws
IdentityRuntimeError with code PRINCIPAL_RESOLUTION_FAILED instead of returning the anonymous
principal, and the /api/v1/principal route converts that into a sanitized 503, never a 200
anonymous response. This is a deliberate, documented, imperfect mitigation — a small race window
remains between the probe and Better Auth's own lookup — not a claim of perfect detection; a future
slice could tighten it (for example, by tracking the underlying connection pool's own error state)
without changing this boundary's public shape.
Ekklesia user ID and principal (extends ADR 0010)
EkklesiaUserId was, in Phase 4A, a plain Zod-validated string with no nominal typing —
confirmed by inspection before this PR began. It is now branded via Zod's .brand<"EkklesiaUserId">():
a bare string is no longer assignable to EkklesiaUserId without passing through
parseEkklesiaUserId/safeParseEkklesiaUserId. The brand is type-system-only — it adds no runtime
wrapper, tag, or field, so a value still serializes as a plain opaque string (JSON.stringify,
string concatenation, and equality all behave exactly as before). Validation still asserts only the
constraints Ekklesia itself owns (non-empty after trimming, a generous 128-character upper bound)
and still does not assume any particular format. In particular it does not assume UUIDs: Phase
4A's schema stores id: text("id").primaryKey() with Better Auth's default application-generated
id mode, an opaque base62-style string, not a database-generated UUID — confirmed by inspecting
core/src/identity/schema.ts and the pinned Better Auth ID-generation documentation. Asserting a
UUID shape here would have been asserting something Ekklesia does not actually guarantee.
EkklesiaPrincipal (ADR 0010) is unchanged in shape: {kind: "anonymous"} or
{kind: "authenticated", userId}. This PR does not add email, name, image, email-verification
status, session ID, session expiry, session token, account/provider details, roles, permissions,
membership status, or volunteer relationships to it. The general principal stays exactly as narrow
as ADR 0010 defined it.
HTTP surface
Better Auth is mounted at exactly /api/auth/* (app.on(["POST", "GET"], "/api/auth/*", ...)),
matching Better Auth's own documented Hono integration pattern. All Ekklesia-owned APIs live under
a versioned /api/v1/* namespace; this slice adds exactly one: GET /api/v1/principal, returning
the existing EkklesiaPrincipal contract as JSON ({"kind":"anonymous"} or
{"kind":"authenticated","userId":"..."}) with Cache-Control: no-store and a
Vary: Origin, Cookie response header. No generalized authorization middleware is added; there is
nothing yet for it to authorize.
apps/api is a composition root and transport host, not a Surface
apps/api (workspace glob apps/* already existed per ADR 0002/repository-boundaries.md) owns
HTTP composition and process lifecycle. It depends on @ekklesia/core, @ekklesia/contracts,
hono, and @hono/node-server — Hono lives in the API application, never in Core, keeping Core
importable by any future transport without forcing an HTTP framework dependency on it.
scripts/check-boundaries.ts's existing apps-are-composition-roots rule already forbids an app
from importing another app, a builder, or a module; it does not restrict app → core, so no
boundary-checker change was needed to let apps/api depend on @ekklesia/core directly. app.ts
exports a pure createApiApp({runtime, trustedOrigins}) factory (no I/O, testable via app.fetch
directly with an injected fake runtime); server.ts separately exports a testable startApi(config)
composition function (constructs the database, the identity runtime, the app, and starts the real
HTTP listener, returning {app, server, shutdown}) and a process-level main() that only reads
environment variables, calls startApi, and wires SIGINT/SIGTERM to the returned shutdown()
— gated behind the same isDirectExecution() guard already used by core/src/cli/doctor.ts, so
importing server.ts (as tests do, to reach startApi) never starts a server or reads the
environment.
The server never applies migrations
apps/api requires an already-migrated PostgreSQL database. It has no migration-discovery,
planning, or execution code at all — it only ever opens a Drizzle connection via Core's existing
createIdentityDrizzleDatabase (ADR 0010) and queries through it. There is no code path by which
starting the API creates the migration history table, creates the identity tables, or executes any
DDL. Operators must run pnpm migrations:apply first, exactly as ADR 0009 already requires for
every other Core or module migration.
Origin, CORS, cookies, and CSRF
The API reuses Phase 4A's typed IdentityConfig.trustedOrigins as the single source of truth for
both Better Auth's own trusted-origin/CSRF checks and the API's CORS policy — there is no second,
competing origin configuration. createApiApp's CORS middleware (hono/cors) uses a function
origin, not a static string or "*": it reflects a request's Origin header back only when that
exact origin is present in the configured allowlist, and returns nothing (no CORS header at all)
otherwise. This is the safe, standard way to combine per-request dynamic origins with
credentials: true — the Fetch spec forbids Access-Control-Allow-Origin: * together with
Access-Control-Allow-Credentials: true, and Hono's own middleware does not enforce that
constraint for you, so an exact allowlist match (never a wildcard) is required here regardless.
CORS is applied to both /api/auth/* and /api/v1/*; only GET, POST, and OPTIONS and only
the Content-Type header are permitted, matching what these two routes actually need. A request
that legitimately carries no Origin header (native/server-to-server clients) is never blocked by
this CORS layer — CORS is a browser-enforced, response-header-only mechanism; omitting the header
for an unrecognized origin does not prevent the server from processing the request, and Better
Auth's own origin/CSRF checks (below) independently protect state-changing calls regardless of
CORS.
Correction — see ADR 0012. The paragraphs below describe what was verified and, honestly, not yet understood at the time this ADR was accepted. ADR 0012 found the root cause of the Vitest non-reproduction described here (the pinned Better Auth release silently skips its own origin/CSRF checks whenever
isTest()is true unlessadvanced.disableOriginCheckis explicitly set — Vitest's defaultNODE_ENV=testtriggers exactly that), fixed it by settingadvanced.disableCSRFCheck/disableOriginCheckexplicitly tofalse, and added the automated coverage this ADR states does not exist. This section is left as originally accepted, as the historical record; ADR 0012 is now authoritative for this behavior.
Better Auth's own CSRF/origin protections are untouched: advanced.disableCSRFCheck and
advanced.disableOriginCheck are never set. Verified directly and repeatedly against the pinned
release, through the real, compiled apps/api HTTP server (not just a direct auth.handler()
call) driven by a plain Node script: any non-GET/HEAD/OPTIONS request that carries a
Cookie header must also carry a trusted Origin (or Referer) header, or Better Auth itself
rejects it with 403 MISSING_OR_NULL_ORIGIN (unrecognized origin: 403 INVALID_ORIGIN) — a
sign-out call with a valid session cookie but an untrusted Origin is reliably rejected by
Better Auth itself, before this ADR's CORS layer is even involved, and the presented session
remains valid afterward since the rejected call never reaches sign-out logic. This is Ekklesia's
baseline, always-on CSRF guarantee for every cookie-bearing state-changing call, and it is real,
reproduced, production behavior of the pinned release running this exact code.
That guarantee is not, however, part of the automated pnpm test:migrations:integration suite.
While building this ADR, the identical scenario — same compiled server, same request sequence —
could not be made to reproduce reliably when driven through Vitest specifically: the rejection
silently does not fire, even in a single isolated test with its own fresh database and API
instance, and even with NODE_ENV forced to "production" inside the test process. The same is
true of Better Auth's documented stricter "first-login CSRF" check for email sign-in/sign-up
(validating Origin/Referer even before any cookie exists), which was also observed to stop
firing after several earlier sign-in/sign-up calls in the same process. Both were reproduced
correctly and repeatedly outside Vitest and were not pinned down to a documented, reproducible
cause inside the time available for this slice, so apps/api/tests/api-http.integration.ts
deliberately does not assert either behavior rather than commit a flaky or misleading automated
test — it documents the manual reproduction inline instead. What the automated suite does assert
is the layer this repository's own code owns end-to-end: CORS never returns
Access-Control-Allow-Origin for an untrusted origin, which is the actual browser-enforced
boundary. A future slice touching authentication endpoints should re-verify Better Auth's
server-side origin rejection directly (as this ADR did) before relying on it changing.
Cookies keep Better Auth's own defaults: httpOnly: true always, sameSite: "lax" always, and
secure driven by the configured baseUrl's protocol (Better Auth treats an https:// baseURL
as secure regardless of NODE_ENV). advanced.crossSubDomainCookies is not enabled and no cookie
Domain attribute is set, so every cookie remains host-only, matching ADR 0010's direction.
Trusted proxy headers (advanced.ipAddress.trustedProxies, ipAddressHeaders, or any
X-Forwarded-* trust) are not configured in this slice; a deployment behind a reverse proxy is
future work, and accepting spoofable forwarded headers without a proxy policy in place would be
worse than not trusting them at all.
Rate limiting
Better Auth's built-in rate limiting is always enabled (rateLimit.enabled: true), including in
test/local composition — it is never disabled to make testing convenient. Its endpoint-specific
built-in rules (by default, a strict 3 requests per 10 seconds on every /sign-in* and /sign-up*
path) are preserved unmodified in production composition. Test composition alone may inject a
narrow, explicit rateLimitOverride (verified empirically to work via Better Auth's own
rateLimit.customRules targeting /sign-in/email and /sign-up/email specifically) so an
integration suite that legitimately issues many sign-in/sign-up requests in a short window is not
itself rate-limited; production composition (apps/api/src/server.ts's main()) never sets this
override. Storage remains the default in-memory backend (rateLimit.storage unset). This has two
honest, documented limitations carried forward from ADR 0010's rate-limiting-adjacent guidance and
confirmed against the pinned release: it is per-process, so it provides 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 per-path bucket rather than a genuine per-client-IP bucket
when it cannot resolve a real client address. Neither limitation is solved by trusting spoofable
X-Forwarded-* headers, which this PR deliberately does not do. Public signup is therefore
rate-limited, but this is not yet the final, distributed, production-grade abuse-control design.
Alternatives
- Letting the first registered account become an administrator automatically was rejected outright per explicit product direction: signup order must never be an authorization input, and Core has no owner capability to grant safely in this slice regardless.
- Adding an
isAdmin/isOwner/role column now, "to be ready for" Phase 4C, was rejected: an unused authorization field invites exactly the kind of accidental elevation this ADR forbids, and Phase 4C's bootstrap command is explicitly required to be an auditable operator action, not a schema default. - Leaving
autoSignInat its default (true) was rejected: it would immediately grant a session (and disable Better Auth's built-in enumeration protection) on every signup, including duplicate attempts against an existing email, which is a weaker default than the one Better Auth itself documents for exactly this public-registration scenario. - Trusting
getSession'snullresult unconditionally as "anonymous" was rejected: the pinned release's own source shows it swallows infrastructure failures, and the task's authorization ambiguity concern is real — a later authorization system consuming a falsely-anonymous principal during a database outage would be a silent security-relevant bug, not a graceful degradation. - Enabling
advanced.ipAddress.trustedProxies/ipAddressHeadersnow, to give rate limiting a real per-client bucket, was rejected: there is no deployment proxy policy yet, and accepting forwarded IP headers without one would let any direct caller spoof its rate-limit bucket. - A wildcard or reflect-any-origin CORS policy was rejected as incompatible with
credentials: trueper the Fetch specification, and as a direct violation of "allow only exact configured trusted origins." - Reimplementing Better Auth's session validation directly against the identity tables (to sidestep
the
getSession-swallows-errors problem entirely) was rejected: it would create a second, divergent session-validation implementation alongside Better Auth's own, exactly the kind of duplicated authentication logic Core is meant to avoid.
Consequences
A person can now publicly register, sign in, be resolved to an EkklesiaPrincipal by any code
holding the narrow Core identity runtime, and sign out — all while Better Auth stays fully
contained behind Core's contracts and Core stays fully unaware of Hono. Every account, first or
five-hundredth, is provably identical in privilege. What remains explicitly unavailable after this
PR: any role, permission, grant, or platform-owner capability; any relationship-derived access;
Dashboard, Website, or Mobile UI; email delivery, email verification, or password recovery; any
provider beyond email/password; and any deployment-proxy or TLS decision. The next expected slice
is Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
platform-owner), an explicit, auditable platform-owner bootstrap command, and the beginning of
reversible relationship-derived access — not a Dashboard Surface, which still depends on an
authorization model that does not exist yet.