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
[--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 owner@example.org"; 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 { return new Promise((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 { return new Promise((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 { 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 { 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; }); }