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.
|
||||
@@ -19,3 +19,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
||||
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
|
||||
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
|
||||
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
|
||||
|
||||
@@ -78,18 +78,29 @@ definition entrypoints, which the module contract requires to be side-effect-fre
|
||||
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
|
||||
it honestly skips object storage because no production Core storage service exists yet.
|
||||
|
||||
Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema
|
||||
Phase 4A added the Core identity foundation. Core owns a Better Auth-backed identity schema
|
||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
|
||||
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
|
||||
database client. That schema ships as a first-class Core-owned migration source that reuses the
|
||||
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
|
||||
established, ordered before module migrations in the same aggregate. See
|
||||
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md) and
|
||||
[the identity development guide](../development/identity.md). Phase 4A does not mount an HTTP
|
||||
handler or expose a sign-in endpoint; no user can authenticate yet.
|
||||
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md).
|
||||
|
||||
The next architectural phase is identity HTTP transport and an explicit enrollment policy. Other
|
||||
target architecture not yet implemented includes production approval-provider composition,
|
||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts
|
||||
Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind
|
||||
Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not
|
||||
depend on Hono. Public email/password registration is intentional; every account receives
|
||||
identical lowest-privilege baseline status, and signup order never grants elevation. See
|
||||
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
|
||||
[the API guide](../development/api.md), and
|
||||
[the identity development guide](../development/identity.md) for the underlying schema and
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
|
||||
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
|
||||
permission.
|
||||
|
||||
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
|
||||
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
|
||||
command. Other target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
|
||||
contracts, dashboard, Builder, and reference feature modules.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# 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](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
|
||||
full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
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](https://www.better-auth.com/docs/authentication/email-password) 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`. |
|
||||
|
||||
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):
|
||||
|
||||
```json
|
||||
{ "kind": "anonymous" }
|
||||
```
|
||||
|
||||
Authenticated:
|
||||
|
||||
```json
|
||||
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
|
||||
```
|
||||
|
||||
Infrastructure failure (the identity database could not be reached while resolving the principal —
|
||||
never silently reported as anonymous):
|
||||
|
||||
```json
|
||||
{ "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 deferred to Phase 4C**, which must introduce an explicit,
|
||||
auditable operator command that 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, and its default password-length policy
|
||||
(8–128 characters) is kept unmodified.
|
||||
|
||||
## 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.
|
||||
|
||||
Better Auth's own CSRF/origin protections are never disabled
|
||||
(`advanced.disableCSRFCheck`/`advanced.disableOriginCheck` are never set). Verified directly
|
||||
against the running compiled server: any non-`GET`/`HEAD`/`OPTIONS` request carrying a session
|
||||
cookie must also carry a trusted `Origin` (or `Referer`), or Better Auth itself rejects it with
|
||||
`403`. See ADR 0011 for the exact verification method and an important caveat about reproducing
|
||||
part of this guarantee inside the automated test suite.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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()` is idempotent: it
|
||||
stops the HTTP listener from accepting new connections and lets in-flight requests finish, then
|
||||
closes the PostgreSQL pool. The executable entry point wires `SIGINT`/`SIGTERM` to call it and
|
||||
exits afterward; a startup failure after the database pool was already created (identity
|
||||
construction failure, or the server failing to bind its listen address) always closes that pool
|
||||
before the failure propagates.
|
||||
|
||||
## 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.
|
||||
|
||||
```text
|
||||
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: Core authorization (public / authenticated-baseline / relationship-derived / grants /
|
||||
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
|
||||
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
|
||||
that does not exist yet.
|
||||
@@ -1,11 +1,13 @@
|
||||
# Identity
|
||||
|
||||
Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a
|
||||
Phase 4A established Core's identity foundation: a Core-owned Better Auth database schema, a
|
||||
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
|
||||
Auth construction boundary. It does not expose a sign-in endpoint, mount an HTTP handler, or make
|
||||
any provider or enrollment decision. See
|
||||
[ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for the full
|
||||
architectural decision; this page is the practical developer guide.
|
||||
Auth construction boundary. Phase 4B built the HTTP transport on top of it — see
|
||||
[API](api.md) and
|
||||
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for
|
||||
public registration, sign-in, and principal resolution over HTTP. This page covers the identity
|
||||
foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md)
|
||||
for that architectural decision.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -18,7 +20,8 @@ surface concern. Better Auth is an implementation detail contained entirely behi
|
||||
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
|
||||
what Core exposes.
|
||||
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
|
||||
never by querying Better Auth's tables directly. No such Core API exists yet.
|
||||
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that
|
||||
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet.
|
||||
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
|
||||
model. Church membership, ministry roles, contact history, attendance, permissions, and other
|
||||
module-specific fields must never be added to the identity tables; a future, separately designed
|
||||
@@ -117,8 +120,8 @@ required. It is part of `pnpm verify`.
|
||||
## Required identity configuration
|
||||
|
||||
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
|
||||
`core/src/identity/factory.ts` constructs Better Auth. This document intentionally contains no
|
||||
real values — configure them per environment, never commit them:
|
||||
`core/src/identity/runtime.ts`'s `createIdentityRuntime` constructs Better Auth. This document
|
||||
intentionally contains no real values — configure them per environment, never commit them:
|
||||
|
||||
| Value | Requirement |
|
||||
| --- | --- |
|
||||
@@ -133,12 +136,25 @@ raises a sanitized `IdentityConfigError` carrying only a stable code, never the
|
||||
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
|
||||
ADR 0010.
|
||||
|
||||
## The identity runtime (Core's Better Auth transport boundary)
|
||||
|
||||
`@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})`
|
||||
(`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen
|
||||
`EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities:
|
||||
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
|
||||
normal responses, including its own error responses, pass through unmodified) and
|
||||
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
|
||||
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else
|
||||
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
|
||||
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
|
||||
only current consumer of this boundary; see [API](api.md).
|
||||
|
||||
## What is not operational yet
|
||||
|
||||
No HTTP server or API route exists, Better Auth's handler is not mounted, and there is no sign-in
|
||||
or sign-up endpoint, public registration, invitation flow, email delivery, password reset, email
|
||||
verification, social or OAuth provider, passkey, magic link, two-factor authentication, or roles
|
||||
and permissions model. **No user can authenticate through this repository yet.** The next expected
|
||||
slice is identity HTTP transport and an explicit enrollment policy (for example, how the first
|
||||
administrator account is created) — not a Dashboard Surface, which depends on that transport slice
|
||||
existing first.
|
||||
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
||||
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
|
||||
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
|
||||
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
|
||||
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
|
||||
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
|
||||
Surface, which still depends on an authorization model that does not exist yet.
|
||||
|
||||
@@ -130,9 +130,11 @@ Result: READY WITH WARNINGS
|
||||
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
|
||||
and migration smoke remain the authoritative automated evidence because they exercise formatting,
|
||||
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
|
||||
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
|
||||
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
|
||||
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
|
||||
not repeat. Core authorization and an explicit platform-owner bootstrap command are the next
|
||||
architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime
|
||||
module health, or production storage yet, and it never constructs an operational Better Auth
|
||||
instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the
|
||||
identity HTTP transport this repository now has.
|
||||
|
||||
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
|
||||
created from `.env.example`:
|
||||
|
||||
@@ -13,11 +13,16 @@ These commands exercise package tests, repository boundaries, type checking, off
|
||||
module migration discovery, identity schema drift detection, formatting, linting, and builds.
|
||||
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
||||
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
||||
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
|
||||
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
|
||||
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
|
||||
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
|
||||
collision rejection) — all without a live database.
|
||||
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
|
||||
configuration validation (secret, base URL, and trusted-origin rules), the Core identity runtime
|
||||
(explicit construction, exact schema mapping, only email/password and rate limiting enabled,
|
||||
sanitized failures, and that a database outage during principal resolution is never silently
|
||||
reported as anonymous), and Core migration discovery (deterministic ordering, the reserved `core`
|
||||
identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover
|
||||
its pure Hono app factory (route shapes, CORS allow/deny/preflight, deterministic 404 and sanitized
|
||||
error responses), its runtime configuration, and that importing any of its modules — including the
|
||||
executable server module without calling its exported `main` — never reads the environment,
|
||||
connects to PostgreSQL, or starts a server.
|
||||
|
||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||
database.
|
||||
@@ -52,6 +57,22 @@ staying byte-for-byte unchanged once Core is introduced, and a later module migr
|
||||
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
|
||||
advisory lock.
|
||||
|
||||
A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in the same
|
||||
`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly
|
||||
migrated disposable database and exercises it with real HTTP requests: public sign-up (with no
|
||||
automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only
|
||||
`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and
|
||||
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie
|
||||
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
|
||||
producing identical principal shapes with no elevation and no owner/role/permission data at the
|
||||
database level, no plaintext password ever stored, CORS never returning an allow-origin header for
|
||||
an untrusted origin, Better Auth's handler being unreachable outside `/api/auth/*`, a database
|
||||
outage during principal resolution producing a sanitized `503` rather than a false anonymous `200`,
|
||||
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
|
||||
shutdown. See [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md)
|
||||
for one CSRF/origin guarantee that was verified manually against the real server but could not be
|
||||
reproduced reliably inside this test runner, and is documented rather than silently asserted.
|
||||
|
||||
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
||||
the following pull-request status context to `main` branch protection:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user