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;
});
}