feat: expose core identity transport
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user