334 lines
25 KiB
Markdown
334 lines
25 KiB
Markdown
# 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 to
|
|
`auth.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 a `Response`) is converted into a sanitized `IdentityRuntimeError`.
|
|
- `resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal>` — resolves the caller's
|
|
`EkklesiaPrincipal`, 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](0012-identity-origin-csrf-protection-under-test.md).** 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 unless `advanced.disableOriginCheck` is explicitly set — Vitest's default `NODE_ENV=test`
|
|
> triggers exactly that), fixed it by setting `advanced.disableCSRFCheck`/`disableOriginCheck`
|
|
> explicitly to `false`, 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 `autoSignIn` at 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`'s `null` result 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`/`ipAddressHeaders` now, 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: true` per 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.
|