75324af04503caa46f80cc296763626563ef72e8
13
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ea28f8aa2e |
feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
fa3021e49b |
feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a single message. Better Auth has been configured with emailAndPassword since ADR 0011 and neither of its mail hooks — sendResetPassword and emailVerification.sendVerificationEmail — was ever implemented, so email verification, password recovery, and invitations were all blocked on the same missing thing. That missing thing is a transport, not a Google Workspace integration. Email is now a host-provided infrastructure service with a provider seam, shaped exactly like object storage: feature code says "send this transactional message" and the provider owns the host, port, TLS, credentials, connection behaviour, and every transport error. No provider's hostname appears anywhere in Core, and a test asserts it. Google Workspace SMTP Relay is the expected first deployment and a compatibility target — the same variables address Microsoft 365, a transactional provider, or a local Postfix. The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or HTML, an optional reply-to, and nothing else. There is no `from` field at all — the sender is host configuration, so no feature and no future module can choose who mail appears to come from, which is how a platform otherwise acquires an impersonation primitive. Control characters are refused rather than stripped in addresses, display names, subjects, and reply-to values; writing that rule down was not enough, because the first implementation trimmed an address before checking it and silently accepted a trailing carriage return until a test caught it. Security modes are named rather than inferred from a boolean and a port, which is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and hope STARTTLS is offered". `starttls` sets requireTLS so a stripped upgrade fails the send instead of continuing in plaintext, production refuses `plaintext` outright, and certificate verification has no off switch. Credentials are optional as a pair, because a relay authorized by source IP has neither and requiring them would make the intended production arrangement unconfigurable; half a pair fails loudly. Connection, greeting, and socket timeouts are all explicit — the object-storage pass already showed what a network client on library defaults does to a request. Nothing retries: a failure after the message data has gone out is ambiguous, and retrying it turns one password-reset request into several emails, so this is a single bounded attempt and deliberately not an exactly-once promise. Email stays optional. An installation with no SMTP host is a complete deployment, nothing sends at startup, and constructing the transport opens no socket. There is no POST /api/v1/send-email and no route that touches the provider — an authenticated send endpoint is an open relay with extra steps. Modules get nothing either; services are added one at a time when something real needs one, and nothing does yet. Proven against a real in-process SMTP server rather than a mock: delivery with the host's sender on the wire, authentication and its sanitized refusal, a rejected mailbox distinguished from an outage, a refused connection, a silent server hitting its deadline, and a starttls transport refusing to continue when the upgrade is not offered. No test sends real email and no live credential exists in this repository or in CI. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
80189c8ae2 |
feat: give display devices their own paired identity
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c8fa13dd6a |
feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
a8c7c50b4d | feat: add browser auth shell | ||
|
|
6912680113 |
fix: prevent migration timeout overrides
pg-connection-string retains arbitrary query keys, so the driver's own configuration key connectionTimeoutMillis reaches ConnectionParameters straight from a URL — and it was not on the rejected list, while spellings that do nothing were. Rejecting it closes that gap. The precedence claims that guard was built on were partly wrong, so every source was measured against the installed [email protected].0, a real blackhole listener, and a real PostgreSQL rather than inferred from the merge: statement_timeout=0 / =900000 server reported 0 and 15min — wins outright query_timeout=0 overrides the field, but the parsed value is the string "0", which is truthy, so every query gets a 0 ms read timer and fails nondeterministically — it breaks the client rather than loosening the bound connectionTimeoutMillis=0/900000 pollutes connectionParameters.connect_timeout (10 -> 0 or 900 s), but that field only feeds getLibpqConnectionString(), which the pure-JS client never calls; the effective timer was unchanged, with an explicit 500 ms still expiring at ~500 ms either way connect_timeout=0 no effect: recomputed from the explicit field options=-c statement_timeout=… no effect, including a backslash-escaped spelling — the named startup parameter wins PGOPTIONS / PGCONNECT_TIMEOUT no effect unrelated parameters no effect, deliberately untouched Only statement_timeout is a genuine downgrade. The rest are rejected as unsupported configuration and defense in depth: no legitimate DATABASE_URL carries a driver option, the polluted connect_timeout would take effect on the native binding, and query_timeout produces intermittent failures an operator could not diagnose. That distinction is now stated where it was previously asserted wrongly — the earlier comment and ADR text claimed connect_timeout overrode the explicit field, which measurement disproved. Rejection still happens at the one shared construction boundary, before any pg.Client exists, with the existing fixed POSTGRES_MIGRATION_CONFIGURATION_INVALID and no URL, host, port, credential, parameter, or value in the message. The 10 s defaults, narrower test overrides, Pass 2 TLS policy and its URL-conflict checks, and every unrelated parameter are unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
2983789779 |
fix: harden migration command execution
Two problems in the migration commands: no timeouts, and SQL split on literal bytes. `migrations:plan` and `migrations:apply` constructed their connection with neither a connection deadline nor an operation deadline — a real server reported `statement_timeout = 0` on the migration connection. Both now default to 10 seconds for the connection deadline, the client query timeout, and PostgreSQL's own statement_timeout, applied at the one shared construction boundary so a future call site cannot silently omit them. History reads, advisory-lock queries, transaction control, migration SQL, and history writes are all covered. The existing lock-wait deadline, the Pass 2 TLS policy, and Doctor's and the bootstrap CLI's own timeouts are untouched, and no environment variable or flag was added. Explicit client fields do not win under the pinned driver. [email protected].0 builds parameters as `Object.assign({}, config, parse(config.connectionString))`, so the URL overrides passed options — verified directly, where an explicit statement_timeout of 10000 alongside `?statement_timeout=0` produced a server-reported 0, while a clean URL produced 10s. A DATABASE_URL carrying connect_timeout, query_timeout, statement_timeout, or an `options` value setting statement_timeout is therefore refused with the existing fixed configuration error, before any client is constructed and without echoing the URL, parameter, or value. Unrelated parameters are left alone. The runner split each migration on the literal `--> statement-breakpoint` bytes. Those bytes inside a string literal, a dollar-quoted function body, a quoted identifier, or a comment tore the migration into fragments that were no longer valid SQL — each case verified to produce two fragments where one was required. Each migration's exact SQL is now passed to the client once, with no values and no prepared-statement name, inside the existing transaction, and its history row inserted only after that batch succeeds. One transaction still spans all pending migrations; advisory locking, checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity are unchanged. The port method is renamed executeStatement to executeMigrationSql so the contract says what it receives. PostgreSQL parses the batch because it owns the only correct parser. A homegrown splitter would have to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments, nested block comments, and escape syntax to answer a question the server already answers, and a parser dependency would add supply-chain surface for the same reason. Drizzle's marker lines are ordinary `--` comments when the batch is sent whole. No custom SQL parser exists here. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
6b6b77c105 |
fix: enforce production transport policy
Every PostgreSQL connection this repository opened was plaintext, in every environment: no connection site set `ssl` at all, so credentials, session tokens, and row data crossed the network in the clear and nothing in configuration, startup, or `pnpm doctor` said so. The public identity URL boundary was also looser than it appeared. `core/src/database/transport.ts` is now the one typed policy. It has exactly two modes: `verify-full` (TLS required, certificate chain verified, and server identity verified against the configured host — the production default) and `disable` (the development/test default, refused outright in production). There is deliberately no encryption-only `require` mode, no chain-only `verify-ca`, and no `rejectUnauthorized: false` escape hatch anywhere in the configuration surface; every loose or aliased spelling is rejected in both environments, so a typo never quietly becomes a weaker mode. Production has no plaintext exception at all, not even for a loopback database — a connection string cannot prove the database is process-local. Verification is certificate *and* hostname. The policy supplies an explicit `checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the pinned driver sets TLS `servername` only for non-IP hosts and Node performs no identity check when it is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA ever signed when connecting by IP. Both halves are proven against a real TLS-enabled PostgreSQL, including that the same connection without the pinned callback succeeds. Every CA, certificate, and key is generated at test runtime and deleted afterwards. A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver merges parsed connection-string values over explicitly passed options, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; that now fails with a stable code, reported by both `pnpm doctor` and the migration CLIs rather than as "failed unexpectedly". One shared environment reader keeps `process.env` out of Core's reusable constructors, and every executable that opens a connection resolves the policy identically: the API (validated before any pool, Better Auth instance, module runtime, or listener exists), both migration commands, the bootstrap CLI, and Doctor's own connection. Doctor reports it as a blocking failure, never a warning, naming only the effective mode. Separately, the identity base URL and every trusted origin must now be exactly one canonical origin — the WHATWG origin serialization must equal the supplied string — so a path, trailing slash, credentials, explicit default port, or non-canonical case is rejected rather than normalized into something that no longer matches what a browser sends. Production's loopback rejection now classifies the address instead of comparing against three literals, covering `.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped forms. This is a configuration-shape guarantee only: it does not resolve DNS or prove reachability, and TLS protects the connection, not the exposure. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
7a0f624fc3 |
fix: distinguish identity session failures
Principal resolution could not tell a broken session store from a genuinely anonymous request, and did unnecessary database work for callers that never presented a session at all. Both are corrected here. Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an otherwise anonymous request caused an identity database round trip an unauthenticated caller could drive with a header they chose themselves. `hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split on `;`, name is the text before the first `=`, trimmed) and compares the whole name exactly — never `includes`, a substring test, or an unanchored regular expression — so `better-auth.session_token_x`, `x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are all correctly not the session cookie. A duplicated name still reports true: ambiguity fails safe towards checking, and reporting true never grants access on its own. The cookie name is not a second hard-coded copy. One `betterAuthOptions` object now feeds both `betterAuth()` and Better Auth's own supported `getCookies` helper, so the detected name — including the `__Secure-` prefix an https baseURL implies — is derived from the exact configuration Better Auth itself uses, and drift is structurally impossible rather than merely tested. The readiness check itself proved almost nothing: `select 1` still succeeded against a dropped session table, a drifted or removed column, or a revoked SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely anonymous request. `probeSessionStore` now plans a real read of the session relation and the exact columns the adapter depends on, under `where false`: PostgreSQL resolves the relation, every column, and the caller's privilege at plan time while matching zero rows, so no session row, token, or user id can be read, logged, or enumerated by the check. It uses the injected pool, never a second connection, and stays bounded by the existing timeout. A missing relation, dropped column, revoked privilege, unreachable connection, and query timeout now all produce the same sanitized 503 with no relation name, column name, SQL, driver text, host, or stack — proven against real PostgreSQL, since that is the only place a broken store looks exactly like an anonymous request. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c38b572778 |
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015 where their original text is now inaccurate. Sweep docs/development/api.md, docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md, and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness; correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching gap in README's current-status narrative. Extend the module template with a permissioned GET /records route reading through context.services.database, using the canonical createModuleTable helper, with matching template tests. |
||
|
|
d9b73fd81a | fix: harden identity HTTP transport | ||
|
|
91d8f5946f | feat: expose core identity transport | ||
|
|
679a5d225c |
feat: establish core identity foundation
Adds Core-owned identity contracts, a Core-owned Better Auth database schema (ekklesia_core_user/session/account/verification), a first-class Core-owned migration source that coexists with module-owned migrations under the exact existing migration system, and an explicit Better Auth construction boundary. No authentication endpoint or sign-in UI is exposed yet (ADR 0010). Co-Authored-By: Claude Sonnet 5 <[email protected]> |