Files
Ekklesia/core/src/authorization/initial-owner.ts
T
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
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]>
2026-08-25 07:21:17 -05:00

324 lines
15 KiB
TypeScript

import type { EkklesiaUserId } from "@ekklesia/contracts";
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
import { and, eq, sql } from "drizzle-orm";
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
import type { IdentityDrizzleDatabase } from "../identity/database.js";
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_ALREADY_COMPLETED"
| "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
{
INITIAL_OWNER_ALREADY_COMPLETED:
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
INITIAL_OWNER_IDENTITY_NOT_CREATED:
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
INITIAL_OWNER_IDENTITY_UNUSABLE:
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
},
);
/**
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
* messages are written to be read by an operator at a terminal, so each one states what happened
* *and* what is still true about the installation — in particular whether bootstrap remains
* available, which is the only fact that determines what they should do next.
*/
export class InitialPlatformOwnerError extends Error {
readonly code: InitialPlatformOwnerErrorCode;
constructor(code: InitialPlatformOwnerErrorCode) {
super(initialOwnerMessages[code]);
this.name = "InitialPlatformOwnerError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
// operation against the grant it performs. This serializes whole invocations; that one serializes
// the grant transaction itself, and both remain in force.
const initialOwnerLockNamespace = 1_500_000_003;
const initialOwnerLockKey = 1_500_000_004;
export interface CreateInitialPlatformOwnerDependencies {
readonly authorizationDb: AuthorizationDrizzleDatabase;
readonly identityDb: IdentityDrizzleDatabase;
readonly createIdentity: BootstrapIdentityCreator;
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
* Production composition leaves it unset and the real single-use bootstrap is used. */
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
}
export interface CreateInitialPlatformOwnerInput {
readonly email: string;
readonly password: string;
/** The display name the identity model requires. */
readonly name: string;
}
export interface InitialPlatformOwnerResult {
readonly email: string;
readonly userId: EkklesiaUserId;
/** `created` when this call made the identity; `adopted` when one already existed for that
* address and only ownership was granted. Reported so an operator is never told something was
* created when it was not. */
readonly identity: "adopted" | "created";
}
interface ExistingIdentity {
readonly userId: string;
readonly hasCredential: boolean;
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
readonly storedEmail: string;
}
/**
* Finds an identity by email, case-insensitively.
*
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
* stores (verified against the pinned release — `[email protected]` persists as
* `[email protected]`), so an exact comparison would fail to find the very account this operation
* had just created and would report it missing.
*/
async function findIdentityByEmail(
db: IdentityDrizzleDatabase,
email: string,
): Promise<ExistingIdentity | undefined> {
const rows = await db
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
.limit(1);
const found = rows[0];
if (!found) {
return undefined;
}
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
// owner that cannot log in and no second chance.
const credentials = await db
.select({ id: ekklesiaCoreAccount.id })
.from(ekklesiaCoreAccount)
.where(
and(
eq(ekklesiaCoreAccount.userId, found.id),
eq(ekklesiaCoreAccount.providerId, "credential"),
),
)
.limit(1);
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
}
/**
* Marks one identity's address verified.
*
* A direct column update rather than an identity-system call, because the pinned release exposes no
* supported way to mark an address verified other than consuming a token it emailed — which is the
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
* one that verified normally; nothing here invents a parallel notion of verification.
*/
async function markIdentityEmailVerified(
db: IdentityDrizzleDatabase,
userId: string,
): Promise<void> {
await db
.update(ekklesiaCoreUser)
.set({ emailVerified: true })
.where(eq(ekklesiaCoreUser.id, userId));
}
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
}
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
}
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
}
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
/**
* Creates the first platform owner of a fresh installation: one identity, then the single-use
* ownership grant.
*
* ## Why the identity is verified rather than trusted
*
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
* a user object carrying an id that was never persisted, with the database left unchanged (verified
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
* response would report an owner it never created and then grant ownership to an id that does not
* exist. So the identity is always read back from the database, and that read — not the sign-up
* response — is what the grant uses.
*
* ## Why an existing identity is adopted rather than refused or overwritten
*
* If the address already has a usable account and bootstrap is still open, ownership is granted to
* it and **its password is left completely alone**. This is precisely the recovery path for the one
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
* password reset would be a silent takeover of an account the operator may not own. Adoption
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
* when it was not.
*
* That is safe here specifically because production has **no** other way to create an identity —
* public signup cannot be enabled at all — so an account present on a fresh installation was put
* there by someone who already had database access.
*
* ## Ordering
*
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
*/
export async function createInitialPlatformOwner(
dependencies: CreateInitialPlatformOwnerDependencies,
input: CreateInitialPlatformOwnerInput,
): Promise<InitialPlatformOwnerResult> {
const email = input.email.trim();
if (email.length === 0) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
}
// Serializes entire invocations against each other. Without it two operators racing on a fresh
// installation would both pass the sentinel check and both create an identity, and only then
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
// alone was already safe; this makes the whole command safe.
return dependencies.authorizationDb.transaction(async (lockTx) => {
await lockTx.execute(
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
);
let alreadyConsumed: boolean;
try {
const consumed = await lockTx
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1);
alreadyConsumed = consumed.length > 0;
} catch {
// An unreachable database and a database without the authorization tables are the same
// situation to an operator running this on a fresh server: the deployment step before this
// one has not happened. Never migrated implicitly — see ADR 0035.
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (alreadyConsumed) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
let existing: ExistingIdentity | undefined;
try {
existing = await findIdentityByEmail(dependencies.identityDb, email);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
}
if (existing && !existing.hasCredential) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
}
// Captured before the read-back below, which cannot distinguish the two: an identity this call
// just created and one it adopted both come back with a credential.
const identity: "adopted" | "created" = existing ? "adopted" : "created";
if (!existing) {
try {
await dependencies.createIdentity({
email,
name: input.name,
password: input.password,
});
} catch (error) {
throw translateIdentityCreationError(error);
}
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
if (!existing) {
// Reached when sign-up reported success without persisting anything.
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
}
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
if (!parsedUserId.success) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
}
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* Every other human account must verify its address by email before it may hold a session.
* This one cannot: it is created on a freshly provisioned server by an operator who already
* holds the database credentials, at a moment when SMTP may not be configured, may not be
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
* would mean a working installation whose administrator can never get in — the platform's
* outbound mail would have to be configured and correct before anyone could sign in to
* configure anything.
*
* The exception is narrow and its authority is the same authority that already makes this
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
* address, exactly once per installation, and it is deliberately performed **before** ownership
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
*/
try {
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
}
try {
const grant =
dependencies.grantOwnership ??
((userId: EkklesiaUserId) =>
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
await grant(parsedUserId.data);
} catch (error) {
if (
error instanceof PlatformOwnerBootstrapError &&
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
) {
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
}
// The identity is real and remains usable; the sentinel rolled back with the grant, so
// re-running this command with the same address adopts it and finishes.
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
}
// The stored address, never the operator's spelling: the identity system lowercases what it
// persists, and telling someone to sign in as `[email protected]` when the account is
// `[email protected]` invites them to conclude the command failed.
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
});
}