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> = 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; } 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 — `MiXeD@Example.ORG` persists as * `mixed@example.org`), 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 { 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 { 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 { 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 `Owner@Example.ORG` when the account is // `owner@example.org` invites them to conclude the command failed. return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data }); }); }