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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c131543abf
commit
c8fa13dd6a
@@ -1,6 +1,7 @@
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./initial-owner.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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_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_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 };
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createInitialPlatformOwner,
|
||||
InitialPlatformOwnerError,
|
||||
} from "../authorization/index.js";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
} from "../identity/index.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation — one identity, then the single-use
|
||||
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
|
||||
* administrator (ADR 0035).
|
||||
*
|
||||
* This exists because the login page has nothing to log in to on a new installation. Public signup
|
||||
* is disabled in every environment and production cannot enable it at all, which is correct and is
|
||||
* not changed here; the consequence was simply that no supported path created the first account.
|
||||
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
|
||||
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
|
||||
* reachable only by someone who already holds the database credentials.
|
||||
*
|
||||
* It creates no HTTP route, starts no server, and applies no migration.
|
||||
*/
|
||||
class InitialOwnerCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InitialOwnerCliError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
|
||||
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
|
||||
"attached, and otherwise read from standard input:\n" +
|
||||
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly email: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
|
||||
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let email: string | undefined;
|
||||
let name: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--email") {
|
||||
email = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--name") {
|
||||
name = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
|
||||
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
|
||||
// wondering why their password was not used.
|
||||
throw new InitialOwnerCliError(
|
||||
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
|
||||
usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (email === undefined || email.trim().length === 0) {
|
||||
throw new InitialOwnerCliError(usage);
|
||||
}
|
||||
return name === undefined ? { email } : { email, name };
|
||||
}
|
||||
|
||||
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
|
||||
* one hidden field, and Node's own raw-mode stdin is enough. */
|
||||
function promptHidden(prompt: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const input = process.stdin;
|
||||
process.stdout.write(prompt);
|
||||
input.setRawMode(true);
|
||||
input.resume();
|
||||
input.setEncoding("utf8");
|
||||
let value = "";
|
||||
const finish = (settle: () => void): void => {
|
||||
input.removeListener("data", onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
process.stdout.write("\n");
|
||||
settle();
|
||||
};
|
||||
const onData = (chunk: string): void => {
|
||||
for (const character of chunk) {
|
||||
if (character === "\r" || character === "\n" || character === "\u0004") {
|
||||
finish(() => resolve(value));
|
||||
return;
|
||||
}
|
||||
if (character === "\u0003") {
|
||||
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
|
||||
return;
|
||||
}
|
||||
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
|
||||
if (character === "\u007f" || character === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
value += character;
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads standard input to end, for automated deployment. */
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let buffer = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(buffer));
|
||||
process.stdin.on("error", () =>
|
||||
reject(new InitialOwnerCliError("Standard input could not be read.")),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
|
||||
* terminal.
|
||||
*
|
||||
* A command-line password is visible in shell history and to every other process on the machine via
|
||||
* the process list, and an environment variable tends to become permanent — a bootstrap password
|
||||
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
|
||||
* reads no environment variable for it and none is added to `.env.example`.
|
||||
*
|
||||
* Interactive use asks twice and compares, because the field is invisible and a typo would create
|
||||
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
|
||||
*/
|
||||
async function readPassword(): Promise<string> {
|
||||
if (process.stdin.isTTY === true) {
|
||||
const password = await promptHidden("Password: ");
|
||||
const confirmation = await promptHidden("Confirm password: ");
|
||||
if (password !== confirmation) {
|
||||
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
|
||||
}
|
||||
return password;
|
||||
}
|
||||
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
|
||||
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
|
||||
// legitimate password characters.
|
||||
const piped = await readStdin();
|
||||
return piped.replace(/\r?\n$/u, "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { email, name } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new InitialOwnerCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
const password = await readPassword();
|
||||
if (password.length === 0) {
|
||||
throw new InitialOwnerCliError("A password is required. No changes were made.");
|
||||
}
|
||||
|
||||
// Asserted before any connection is constructed, so an unsafe production transport never opens a
|
||||
// client at all — identical to every other Core executable.
|
||||
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||
const databaseOptions = {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 30_000,
|
||||
transport,
|
||||
};
|
||||
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
try {
|
||||
const result = await createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name: name ?? email, password },
|
||||
);
|
||||
|
||||
// Never the password, its length, a hash, a session token, a user id, or the database URL.
|
||||
console.log(
|
||||
result.identity === "created"
|
||||
? "Initial platform owner created successfully."
|
||||
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
|
||||
);
|
||||
console.log(`Email: ${result.email}`);
|
||||
console.log("You can now sign in to the Ekklesia dashboard.");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
authorization.close().catch(() => undefined),
|
||||
identity.close().catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function reportInitialOwnerCliError(error: unknown): void {
|
||||
if (error instanceof InitialOwnerCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof InitialPlatformOwnerError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
|
||||
// the password itself if it were ever interpolated into a message upstream.
|
||||
console.error("Initial platform owner creation failed unexpectedly.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportInitialOwnerCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
|
||||
* operator-invoked initial-owner bootstrap and nothing else.
|
||||
*
|
||||
* ## Why this is not `createIdentityRuntime`
|
||||
*
|
||||
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
|
||||
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
|
||||
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
|
||||
* `auth.api.signUpEmail` on the server's own instance is refused with
|
||||
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
|
||||
* assumed. So a fresh production installation has no way to create the one identity it needs.
|
||||
*
|
||||
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
|
||||
* it is an in-process library call reachable only by an operator who already holds the database
|
||||
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
|
||||
* it. Public signup remains disabled in every environment.
|
||||
*
|
||||
* ## Why the library path rather than writing rows
|
||||
*
|
||||
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
|
||||
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
|
||||
* and a password hash would be a second, divergent identity implementation — and the failure mode
|
||||
* is an account that exists but cannot sign in.
|
||||
*
|
||||
* ## Prerequisites this deliberately does not have
|
||||
*
|
||||
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
|
||||
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
|
||||
* through a *separately constructed* instance carrying a different secret — password hashes are
|
||||
* salted per account and independent of the secret. That matters operationally, because it means an
|
||||
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
|
||||
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
|
||||
* configured rather than silently defaulted; this instance never mints a session, so nothing is
|
||||
* ever signed with it and it is discarded when the process exits.
|
||||
*/
|
||||
export type BootstrapIdentityErrorCode =
|
||||
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
|
||||
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
|
||||
|
||||
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
|
||||
Object.freeze({
|
||||
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
|
||||
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
|
||||
"The password is shorter than the identity system allows.",
|
||||
});
|
||||
|
||||
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
|
||||
export class BootstrapIdentityError extends Error {
|
||||
readonly code: BootstrapIdentityErrorCode;
|
||||
|
||||
constructor(code: BootstrapIdentityErrorCode) {
|
||||
super(bootstrapIdentityMessages[code]);
|
||||
this.name = "BootstrapIdentityError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapIdentityInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Creates one credential identity. Resolving says only that the identity system reported success —
|
||||
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
|
||||
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
|
||||
|
||||
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
|
||||
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
|
||||
* creation failure so no library internal, response body, or cause is ever surfaced. */
|
||||
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
|
||||
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
|
||||
if (code === "PASSWORD_TOO_SHORT") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (code === "PASSWORD_TOO_LONG") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (code === "VALIDATION_ERROR") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
|
||||
}
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
|
||||
}
|
||||
|
||||
export function createBootstrapIdentityCreator(
|
||||
db: IdentityDrizzleDatabase,
|
||||
): BootstrapIdentityCreator {
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
|
||||
emailAndPassword: {
|
||||
// No session is minted here. The operator signs in through the ordinary browser transport
|
||||
// afterwards, which is also what proves the account really works.
|
||||
autoSignIn: false,
|
||||
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
|
||||
disableSignUp: false,
|
||||
enabled: true,
|
||||
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
|
||||
// the browser would later reject, or reject one it would accept.
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
logger: {
|
||||
// Better Auth's own log call sites interpolate raw input and exceptions; during account
|
||||
// creation that input includes a password. Nothing it wants to log is ever emitted.
|
||||
disabled: true,
|
||||
},
|
||||
secret: randomBytes(32).toString("base64url"),
|
||||
});
|
||||
|
||||
return async (input: BootstrapIdentityInput): Promise<void> => {
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: input.email, name: input.name, password: input.password },
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateBootstrapIdentityError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./bootstrap-identity.js";
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
describe("create-initial-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("reads no environment variable for the password", async () => {
|
||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||
// the environment, so nothing can make it permanent deployment configuration. Asserted against
|
||||
// the source rather than behaviour, because the absence of a lookup is what matters.
|
||||
const source = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(new URL("../src/cli/create-initial-owner.ts", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
const environmentReads = [...source.matchAll(/process\.env\.([A-Z_]+)/gu)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
expect(environmentReads).toEqual(["DATABASE_URL"]);
|
||||
// No dynamic lookup either, which a named-property scan alone would miss.
|
||||
expect(source).not.toMatch(/process\.env\[/u);
|
||||
// And a password supplied on the command line is refused rather than quietly honoured.
|
||||
expect(source).toContain("--password is not supported");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user