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
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user