feat: create the initial platform owner from an operator command #34

Merged
Zayin_Admin merged 1 commits from phase-7b/initial-owner-bootstrap into main 2026-08-24 03:44:19 +00:00
16 changed files with 1767 additions and 12 deletions
Showing only changes of commit c8fa13dd6a - Show all commits
+42 -3
View File
@@ -52,9 +52,10 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
Email verification, password recovery, invitation/operator enrollment, and a supported production
first-user creation path still do not exist. Public registration remains disabled by default and
prohibited in production. The Dashboard now has a minimal authenticated shell and login/logout flow,
Email verification, password recovery, and invitation/operator enrollment still do not exist.
Public registration remains disabled by default and prohibited in production — and a fresh
production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
but not a finished product UI or a production asset-hosting deployment; jobs, production
approval-provider composition, feature modules, and the public Website remain later slices.
@@ -816,6 +817,44 @@ row, and start nothing — the change takes effect at the next `pnpm api:start`.
that breaks startup can be turned off without the API, since the HTTP lifecycle endpoint is served by
the process such a module takes down.
### Getting the first administrator into a fresh installation
A new installation has a login page and nothing to log in to: public registration is disabled by
default and cannot be enabled in production at all. One explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
docker compose ... up -d # PostgreSQL and the rest of the stack
pnpm migrations:apply # the schema must already exist
pnpm identity:create-initial-owner -- --email [email protected]
pnpm api:start # then sign in at the Dashboard login page
```
It prompts for the password twice, invisibly, and never accepts one as a command-line argument —
`--password` is refused, because an argument is visible in shell history and in the process list.
For automated deployment it reads the password from standard input instead:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for the password and none belongs in `.env`**: it is used once to
create an account, not kept as deployment configuration. The command needs `DATABASE_URL` and
nothing else — no identity secret, no trusted origins, and no public base URL — so the first owner
can be created on a freshly provisioned server before TLS or DNS exist.
It runs **once**. Ownership bootstrap is single-use, so a second run is refused: it creates no
second owner, changes no existing password, and leaves the first owner untouched. It also never
applies migrations — an unmigrated database is reported, not silently fixed. If it created the
identity but failed before granting ownership, running the identical command again adopts that
identity and finishes, leaving its password alone. Creating *additional* users and owners belongs to
user administration, which does not exist yet.
This is not public signup and not a browser setup wizard: there is no `/signup`, `/register`, or
`/setup` route, no unauthenticated bootstrap endpoint, and no setup token. The login page stays
login-only.
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
+687
View File
@@ -0,0 +1,687 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
InitialPlatformOwnerError,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* The initial platform-owner bootstrap, against a real PostgreSQL and the real HTTP transport
* (ADR 0035).
*
* The point of this suite is that it does **not** stop at asserting rows. A fresh installation is
* bootstrapped and then the resulting account is signed in through the exact browser-facing login
* transport Phase 7B Pass 1 established, its principal is read back, and it is used to reach a
* platform-owner-protected resource. Anything less would prove the command wrote a user, not that
* an operator can actually get into their dashboard — which is the only thing this pass is for.
*
* Every scenario runs with `publicSignupEnabled: false`, the production posture, so nothing here
* can accidentally depend on the signup route the real deployment does not have.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const bootstrapCliPath = path.join(
repositoryRoot,
"core",
"dist",
"cli",
"create-initial-owner.js",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const ownerPassword = "correct horse battery staple";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
function makeDatabaseName(label: string): string {
databaseCounter += 1;
return `ekk_owner_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
const result = await client.query<T>(text, [...values]);
return result.rows;
} catch {
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function createDisposableDatabase(label: string): Promise<string> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
return databaseUrl(name);
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-owner-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
}
/** A fresh, migrated installation with no identity and no owner — exactly what an operator has
* after `docker compose up` and `pnpm migrations:apply`. */
async function freshInstallation(label: string): Promise<string> {
const url = await createDisposableDatabase(label);
await applyCoreMigrations(url);
return url;
}
interface BootstrapHandles {
readonly run: (
email: string,
password?: string,
name?: string,
) => ReturnType<typeof createInitialPlatformOwner>;
readonly close: () => Promise<void>;
}
/** Drives the same operation the CLI drives, with the same production composition. */
function bootstrapAgainst(url: string): BootstrapHandles {
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
return {
close: async () => {
await Promise.all([
authorization.close().catch(() => undefined),
identity.close().catch(() => undefined),
]);
},
run: (email, password = ownerPassword, name = "Platform Owner") =>
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name, password },
),
};
}
/** The production posture: public signup disabled, exactly as a real deployment must run. */
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ cookiePair?: string; response: Response }> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? { response } : { cookiePair: firstCookiePair(setCookie), response };
}
const execFileAsync = promisify(execFile);
interface CliOutcome {
readonly code: number;
readonly stdout: string;
readonly stderr: string;
}
/** Runs the real compiled CLI as a real child process, with the password on stdin and never in
* `argv`. */
async function runBootstrapCli(
databaseUrl: string | undefined,
args: readonly string[],
password: string | undefined,
): Promise<CliOutcome> {
const environment: Record<string, string> = {
...(process.env as Record<string, string>),
NODE_ENV: "development",
};
if (databaseUrl === undefined) {
delete environment.DATABASE_URL;
} else {
environment.DATABASE_URL = databaseUrl;
}
const child = execFileAsync(process.execPath, [bootstrapCliPath, ...args], {
env: environment,
});
child.child.stdin?.end(password ?? "");
try {
const { stdout, stderr } = await child;
return { code: 0, stderr, stdout };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return {
code: typeof failure.code === "number" ? failure.code : 1,
stderr: failure.stderr ?? "",
stdout: failure.stdout ?? "",
};
}
}
async function countRows(url: string, table: string): Promise<number> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text from "${table}"`,
);
return Number(rows[0]?.count ?? "0");
} finally {
await client.end().catch(() => undefined);
}
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) {
return;
}
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
});
describe.sequential("initial platform-owner bootstrap", () => {
it("takes a fresh installation all the way to an authenticated owner in the browser", async () => {
const url = await freshInstallation("browserproof");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
let created: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
created = await bootstrap.run(email);
} finally {
await bootstrap.close();
}
expect(created.identity).toBe("created");
expect(created.email).toBe(email.toLowerCase());
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// 1. The exact browser-facing login transport, on a deployment where signup does not exist.
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
const cookie = session.cookiePair as string;
// 2. The principal the browser bootstrap reads.
const principalResponse = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
expect(principalResponse.status).toBe(200);
const principal = (await principalResponse.json()) as {
kind: string;
userId?: string;
};
expect(principal.kind).toBe("authenticated");
expect(principal.userId).toBe(created.userId);
// 3. A platform-owner-protected resource actually opens for them. This is the assertion that
// separates "a row was written" from "the operator can run their platform".
const grants = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`, {
headers: { cookie },
});
expect(grants.status).toBe(200);
// 4. And the same resource is still closed to everyone else.
const anonymous = await fetch(`${origin}/api/v1/grants?userId=${created.userId}`);
expect(anonymous.status).toBe(403);
} finally {
await api.shutdown();
}
});
it("leaves public signup disabled — the owner exists without a signup route existing", async () => {
const url = await freshInstallation("nosignup");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const attempted = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({
email: `intruder-${randomBytes(4).toString("hex")}@example.test`,
name: "Intruder",
password: ownerPassword,
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
// The bootstrap command created an account; it did not open a door for anyone else to.
expect(attempted.status).toBe(400);
expect(await attempted.text()).toContain("EMAIL_PASSWORD_SIGN_UP_DISABLED");
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
// And the owner can still sign in, so disabling signup did not disable the account.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
describe("the guard", () => {
it("refuses a second bootstrap, changes no password, and creates no second owner", async () => {
const url = await freshInstallation("guard");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
// Same address, different password: must not become a password reset.
await expect(bootstrap.run(email, "an entirely different password")).rejects.toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
// A different address must not become a second owner.
await expect(
bootstrap.run(`second-${randomBytes(4).toString("hex")}@example.test`),
).rejects.toMatchObject({ code: "INITIAL_OWNER_ALREADY_COMPLETED" });
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
// The refused second attempt with a new address created no stray identity: the sentinel is
// checked before anything is created, not after.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "an entirely different password")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
});
describe("validation reuses the identity system's own policy", () => {
it("rejects an invalid email, and a password outside the 8–128 bound", async () => {
const url = await freshInstallation("validation");
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("not-an-email-address")).rejects.toMatchObject({
code: "INITIAL_OWNER_EMAIL_INVALID",
});
await expect(
bootstrap.run(`short-${randomBytes(4).toString("hex")}@example.test`, "1234567"),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_SHORT" });
await expect(
bootstrap.run(`long-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(129)),
).rejects.toMatchObject({ code: "INITIAL_OWNER_PASSWORD_TOO_LONG" });
// Exactly the boundary the browser accepts, so the two can never diverge.
await expect(
bootstrap.run(`edge-${randomBytes(4).toString("hex")}@example.test`, "x".repeat(128)),
).resolves.toMatchObject({ identity: "created" });
} finally {
await bootstrap.close();
}
// Every rejection happened before anything was written, and the one success is the only row.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
it("never puts the password into an error", async () => {
const url = await freshInstallation("nosecrets");
const secret = "sup3rSecretBootstrapPassword!";
const bootstrap = bootstrapAgainst(url);
try {
let caught: unknown;
try {
await bootstrap.run("not-an-email-address", secret);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(InitialPlatformOwnerError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(secret);
expect(rendered).not.toContain("postgres");
expect(rendered).not.toContain("select");
} finally {
await bootstrap.close();
}
});
});
describe("an identity that already exists", () => {
it("adopts a usable account and leaves its password untouched", async () => {
const url = await freshInstallation("adopt");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// Exactly the partial failure this guards: the identity committed, the grant never ran.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email,
name: "Half Finished",
password: ownerPassword,
});
} finally {
await identity.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
const bootstrap = bootstrapAgainst(url);
try {
const result = await bootstrap.run(email, "a different password than before");
expect(result.identity).toBe("adopted");
} finally {
await bootstrap.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
// Adoption granted ownership. It did not take the account over: the original password still
// works and the one supplied to the rerun does not.
expect((await signIn(origin, email, ownerPassword)).response.status).toBe(200);
expect(
(await signIn(origin, email, "a different password than before")).response.status,
).not.toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a credential-less row rather than consuming bootstrap on an unusable owner", async () => {
const url = await freshInstallation("unusable");
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "Shell Row", "[email protected]"],
);
} finally {
await client.end().catch(() => undefined);
}
const bootstrap = bootstrapAgainst(url);
try {
await expect(bootstrap.run("[email protected]")).rejects.toMatchObject({
code: "INITIAL_OWNER_IDENTITY_UNUSABLE",
});
} finally {
await bootstrap.close();
}
// The decisive part: bootstrap is still available. Granting ownership to a row nobody can
// authenticate as would have consumed the single use and left the installation unusable with
// no second chance.
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
});
});
describe("partial failure", () => {
it("reports a failed grant honestly and stays recoverable by rerunning", async () => {
const url = await freshInstallation("partial");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await expect(
createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
grantOwnership: () => {
throw new Error("the grant failed after the identity committed");
},
identityDb: identity.db,
},
{ email, name: "Owner", password: ownerPassword },
),
).rejects.toMatchObject({ code: "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" });
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
// The identity committed; the grant did not; the single-use sentinel was never consumed.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(0);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(0);
// Rerunning the identical command finishes the job, which is the whole recovery story.
const retry = bootstrapAgainst(url);
try {
await expect(retry.run(email)).resolves.toMatchObject({ identity: "adopted" });
} finally {
await retry.close();
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
describe("concurrency", () => {
it("gives exactly one owner when several bootstraps race", async () => {
const url = await freshInstallation("race");
const attempts = 5;
const handles = Array.from({ length: attempts }, () => bootstrapAgainst(url));
try {
const outcomes = await Promise.allSettled(
handles.map((handle, index) =>
handle.run(`racer-${index}-${randomBytes(3).toString("hex")}@example.test`),
),
);
const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
expect(fulfilled).toHaveLength(1);
for (const outcome of outcomes.filter((candidate) => candidate.status === "rejected")) {
expect((outcome as PromiseRejectedResult).reason).toMatchObject({
code: "INITIAL_OWNER_ALREADY_COMPLETED",
});
}
} finally {
await Promise.all(handles.map((handle) => handle.close()));
}
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
expect(await countRows(url, "ekklesia_core_platform_owner_bootstrap")).toBe(1);
// The losers created nothing at all. Serializing whole invocations — not just the grant — is
// what keeps a race from leaving signable-in accounts that own nothing behind.
expect(await countRows(url, "ekklesia_core_user")).toBe(1);
});
});
describe("the command itself", () => {
it("succeeds, prints something useful, and leaks nothing", async () => {
const url = await freshInstallation("cli");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const result = await runBootstrapCli(url, ["--email", email], ownerPassword);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Initial platform owner created successfully.");
expect(result.stdout).toContain(email.toLowerCase());
const everything = `${result.stdout} ${result.stderr}`;
expect(everything).not.toContain(ownerPassword);
expect(everything).not.toContain("postgresql://");
expect(everything).not.toContain("password");
const api = await startApiWithoutSignup(url);
try {
// The command's real product: an operator who can now sign in.
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("refuses a password supplied on the command line", async () => {
const url = await freshInstallation("argv");
const result = await runBootstrapCli(
url,
["--email", "[email protected]", "--password", "hunter2hunter2"],
undefined,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("--password is not supported");
expect(await countRows(url, "ekklesia_core_user")).toBe(0);
});
it("fails clearly without a database, and refuses to migrate one itself", async () => {
const missing = await runBootstrapCli(
undefined,
["--email", "[email protected]"],
"whatever",
);
expect(missing.code).toBe(1);
expect(missing.stderr).toContain("DATABASE_URL is required.");
// A database that exists but was never migrated is a deployment step that has not happened,
// and this command says so rather than quietly running migrations for a production operator.
const unmigrated = await createDisposableDatabase("unmigrated");
const result = await runBootstrapCli(
unmigrated,
["--email", "[email protected]"],
ownerPassword,
);
expect(result.code).toBe(1);
expect(result.stderr).toContain("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
const client = new Client({ connectionString: unmigrated });
await client.connect();
try {
const tables = await safeQuery<{ count: string }>(
client,
`select count(*)::text from information_schema.tables where table_schema = 'public'`,
);
expect(Number(tables[0]?.count ?? "0")).toBe(0);
} finally {
await client.end().catch(() => undefined);
}
});
it("refuses a second run at the command level too", async () => {
const url = await freshInstallation("clirerun");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
expect((await runBootstrapCli(url, ["--email", email], ownerPassword)).code).toBe(0);
const second = await runBootstrapCli(url, ["--email", email], "a different password");
expect(second.code).toBe(1);
expect(second.stderr).toContain("INITIAL_OWNER_ALREADY_COMPLETED");
expect(second.stderr).not.toContain("a different password");
expect(await countRows(url, "ekklesia_core_platform_owner")).toBe(1);
});
});
});
+1
View File
@@ -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";
+278
View File
@@ -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 });
});
}
+233
View File
@@ -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;
});
}
+132
View File
@@ -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
View File
@@ -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");
});
});
@@ -0,0 +1,182 @@
# 0035: The first platform owner is created by an operator command, never by a browser
- Status: Accepted
- Date: 2026-08-24
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
[0019](0019-durable-authorization-history-and-grant-management.md),
[0034](0034-host-owned-browser-authentication.md)
## Context
[ADR 0034](0034-host-owned-browser-authentication.md) gave Ekklesia a host-owned login page, and
authorization has had a single-use platform-owner bootstrap since
[ADR 0019](0019-durable-authorization-history-and-grant-management.md). Between them they close
every step of getting an administrator into the Dashboard except the first one.
`pnpm authorization:bootstrap-owner` grants ownership to a user id that **already exists**. It has
no way to bring that user into being. Public signup is disabled by default and production cannot
enable it at all — `IdentityConfig` rejects `publicSignupEnabled` in production outright
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`), because the durable abuse controls that would justify a
public registration endpoint do not exist. That is the right decision and this ADR does not revisit
it.
The consequence was a fresh production installation that deadlocks:
```text
deploy → migrate → login page loads → no account exists → nothing can create one → stuck
```
The only ways through were creating rows by hand or briefly turning on public registration, and both
are exactly what a supported platform should not ask an operator to do.
## Decision
One explicit, operator-invoked command creates the initial identity and grants it platform
ownership:
```
pnpm identity:create-initial-owner -- --email [email protected]
```
It is deliberately the same shape as `pnpm authorization:bootstrap-owner` and `pnpm modules:enable`:
trusted local tooling, reachable only by someone who already holds the database credentials, serving
no HTTP route and mounted nowhere.
### What this is not
No `/signup`, `/register`, or `/setup` route. No unauthenticated bootstrap endpoint, no temporary
setup route, no query-string bootstrap token, and no browser wizard. The login page stays
login-only, and public signup stays disabled in every environment. A regression test asserts that a
deployment which has just bootstrapped its owner still answers `/api/auth/sign-up/email` with
`EMAIL_PASSWORD_SIGN_UP_DISABLED`.
### The identity is created through Better Auth's own sign-up path
Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds are
the identity system's, applied exactly as they are for a browser sign-up. The command writes no user
row and no password hash of its own — a second, divergent identity implementation would eventually
produce an account that exists but cannot sign in.
Because the server's own instance refuses account creation (verified: `auth.api.signUpEmail` on it
returns `EMAIL_PASSWORD_SIGN_UP_DISABLED`), the command constructs a **separate, in-process** Better
Auth instance with sign-up enabled. Its safety comes from where it can be invoked, not from a weaker
policy: it serves no route, nothing in `apps/api` imports it, and the running server's posture is
untouched.
### The sign-up result is verified, never trusted
Better Auth returns a **fabricated success** when the address is already registered — a user object
carrying an id that was never persisted, with the database unchanged. This was verified against the
pinned release, and on a public endpoint it is correct behaviour: it refuses to tell an anonymous
caller which addresses exist.
For this command it is a trap. 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 by address, and that read — never the sign-up response — is what the grant uses.
### Bootstrap runs once, and the existing sentinel decides
The guard is not new and is not weakened. `ekklesia_core_platform_owner_bootstrap` is an explicit
singleton, deliberately *not* inferred from the continued existence of an owner row, and
`bootstrapPlatformOwner` claims it inside the same locked transaction as the grant and its audit
entry. There is no `--force`, no reset, and no override; "consumed" is permanent by design.
This command adds a cheap sentinel check **before** creating an identity, so a closed bootstrap
never leaves a stray account behind. That check is an early exit, not the authority — the grant
re-checks under its own lock, which is what actually makes it single-use.
### An existing identity is adopted, never 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**, reported as `adopted` so nobody is told an account was
created when it was not.
This is the recovery path for the one partial failure the command can have. Sign-up commits through
Better Auth's own connection and the grant runs in a separate transaction, so full transactionality
across the two is not available: 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.
That is safe here specifically because production has no other way to create an identity, so an
account present on a fresh installation was put there by someone who already had database access.
**A credential-less row is refused instead.** A `user` row with no `account` row cannot sign in;
granting ownership to one would consume the single use on an identity nobody can authenticate as and
leave the installation unusable with no second chance. The command reports that and changes nothing,
leaving bootstrap available.
### Failure leaves a recoverable, self-describing state
- Identity created, grant failed → the identity is real, the sentinel rolled back with the grant,
and rerunning the same command with the same address adopts it and finishes. The error says so.
- Nothing is deleted automatically. Removing a successfully created identity to "clean up" would be
destroying an account on the basis of an error whose cause is not known.
### Whole invocations are serialized
The grant alone was already race-proof. The command additionally takes a transaction-scoped advisory
lock — on its own key, distinct from the grant's, so the two cannot deadlock — across the entire
check-create-grant sequence. Without it, two operators racing on a fresh installation would both
pass the sentinel check and both create an identity before one lost the grant, leaving a stray
account that can sign in and owns nothing. A test races five invocations and asserts one owner, one
sentinel, and **one user row**.
### The password never becomes an argument, a variable, or an echo
It is prompted for, invisibly and twice, when a terminal is attached, and otherwise read from
standard input for automated deployment:
```
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
`--password` is refused with an explanation rather than ignored. **No environment variable is read
for it and none is added to `.env.example`**: a bootstrap password belongs to one invocation, not to
a deployment's permanent configuration, and an environment variable is exactly the thing that
becomes permanent. Nothing prints the password, its length, a hash, a token, or the database URL.
### It runs before DNS exists
The command needs `DATABASE_URL` and nothing else. It requires no identity secret, no trusted
origins, and no public base URL — 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, because password hashes are salted per account and independent of the secret.
That matters operationally: an operator can create the first owner on a freshly provisioned server
before TLS, DNS, or the public origin exist. The instance the command builds is given an ephemeral
random secret so it is explicitly configured rather than silently defaulted; it never mints a
session, so nothing is ever signed with it.
The forward-compatibility risk — a future release coupling password hashing to the secret — is held
by the browser-login integration proof, which creates through this path and signs in through the
real server with the real configured secret.
### It never migrates
A database that is unreachable and one that was never migrated are the same situation to an operator
on a fresh server: the deployment step before this one has not happened. The command says so and
changes nothing. Silently migrating a production schema from a bootstrap command would hide exactly
the mistake worth surfacing.
## Consequences
The success criterion is proven end to end rather than at the database: an integration test
bootstraps a fresh installation, signs in through `POST /api/auth/sign-in/email` on a deployment with
signup disabled, reads the principal from `GET /api/v1/principal`, and opens a
platform-owner-protected resource — while the same resource stays closed to an anonymous caller.
Creating *additional* users and owners is not this command's job and is deliberately absent. It
belongs to user administration, which does not exist yet. Until it does, an installation has one
owner, created once.
Rate limiting is unchanged. This is local trusted tooling, not an internet-facing login endpoint,
and Phase 7B Pass 1's in-memory login rate-limit limitation is out of scope here.
The existing `signUpPlatformOwner` test helper in `apps/api/tests/api-http.integration.ts` still
creates accounts over HTTP with public signup enabled. It is left alone: it exists to exercise the
public signup transport itself, which is a different thing from the production bootstrap path, and
collapsing them would weaken that coverage rather than share it.
This is a full-Ekklesia concern. Ekklesia Lite provides module runtime primitives and deliberately
not user authentication, permission enforcement, or managed device identity, so nothing here belongs
in a future Lite contract.
+1
View File
@@ -43,3 +43,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
+23 -6
View File
@@ -294,12 +294,29 @@ before the id is even parsed as a valid `EkklesiaUserId` — this command never
signup order or "the only user in the database"; the operator must name the exact, opaque id
twice.
**The command grants authority; it does not create credentials.** Production public signup is
prohibited, and the repository currently has no invitation flow or operator-owned user-creation
command. A fresh production installation therefore has no supported path to create the initial
identity this command requires. Development integration tests may enable signup, create a user,
bootstrap that existing id, and prove its Dashboard session, but that is test composition — not a
production first-user provisioning workflow. This gap must be closed before launch.
**The command grants authority; it does not create credentials.** It requires a user id that
already exists, which is the right shape when an identity is already there and only ownership is
missing.
For a *fresh* installation, where no identity exists at all, use
`pnpm identity:create-initial-owner` instead
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
pnpm migrations:apply # the database must already be migrated
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
```
It creates the identity through the identity system's own sign-up path and then performs this exact
single-use grant, so both commands consume the same sentinel and neither weakens it. Whichever runs
first is the one bootstrap this installation gets. The password is prompted for invisibly, or read
from standard input for automation; it is never a command-line argument and never an environment
variable. See [Identity](identity.md#creating-the-first-platform-owner) for the full operator
workflow, including its rerun and recovery behaviour.
Development integration tests may still enable signup, create a user, bootstrap that existing id,
and prove its Dashboard session; that remains test composition exercising the public signup
transport, which is a different thing from the production bootstrap path.
### Bootstrap is permanently single-use
+84 -3
View File
@@ -262,6 +262,87 @@ roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
an existing identity; it does not create one. Because production signup is prohibited and no
operator enrollment command exists, creating the initial production identity needed for owner
bootstrap remains an explicit operational gap.
an existing identity; it does not create one. Creating the *first* identity on a fresh installation
is what `pnpm identity:create-initial-owner` is for — see below.
## Creating the first platform owner
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
default and production cannot enable it at all, so one explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
pnpm migrations:apply # the schema must already exist; this command never migrates
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owner@example.org
# Password: (typed invisibly)
# Confirm password:
# Initial platform owner created successfully.
# Email: owner@example.org
# You can now sign in to the Ekklesia dashboard.
```
`--name` is optional and defaults to the email address; the identity model requires a display name,
and asking for one that nobody can edit yet would be a question without a purpose.
### The password never becomes an argument or a variable
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
explanation**, because a command-line argument is visible in shell history and to every process on
the machine through the process list. For automated deployment the password is read from standard
input:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email owner@example.org
```
**No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used
once to create an account; it is not deployment configuration, and an environment variable is
precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash,
a session token, or the database URL.
### Required environment
`DATABASE_URL`, and the same optional transport variables every other Core command honours
(`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity
secret, trusted origins, or a public base URL: none is needed to create an account, so the first
owner can be created on a freshly provisioned server before TLS or DNS exist.
### Password and email rules are the identity system's own
The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up
path a browser would use, not by a separate CLI policy that could drift from it. The address is
normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports
the stored address, so the one you are told to sign in with is the one that exists.
### It runs once
Ownership bootstrap is permanently single-use (see
[Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and:
- creates no second owner,
- changes no existing password,
- creates no stray identity, even when a different address is supplied.
### Rerun and recovery
| situation | behaviour |
| --- | --- |
| Bootstrap already completed | Refused. Nothing changes. |
| Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. |
| A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. |
| Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. |
| Database unmigrated or unreachable | Refused with a clear message. The command never migrates. |
Nothing is deleted automatically on failure: removing an account that was created successfully, on
the strength of a later error, would destroy something an operator may need.
### This is not signup
There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no
temporary setup route, and no setup token. The login page stays login-only, and public registration
stays disabled in every environment — an integration test asserts that a freshly bootstrapped
deployment still refuses `POST /api/auth/sign-up/email`.
Creating additional users or owners is not this command's job. That belongs to user administration,
which does not exist yet.
+38
View File
@@ -506,6 +506,44 @@ outstanding.
consuming the single-use platform-owner bootstrap. This repository does not provide that way
yet.
### The initial-owner command is trusted local tooling
A fresh installation needs a first administrator, and public registration is disabled everywhere and
prohibited in production. `pnpm identity:create-initial-owner` fills that gap
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)). What makes it safe
is **where it can be invoked**, not a weaker policy:
- **It is not reachable from a browser.** No `/signup`, `/register`, or `/setup` route, no
unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. It serves no HTTP
route and nothing in `apps/api` imports it. Its authority is already having the database
credentials.
- **Public signup stays disabled.** The command constructs its own in-process identity instance
rather than relaxing the server's posture. A test asserts that a freshly bootstrapped deployment
still refuses `POST /api/auth/sign-up/email`.
- **The password never lands anywhere durable.** Prompted invisibly or read from standard input;
`--password` is refused rather than ignored, because an argument is visible in shell history and in
the process list. No environment variable is read for it and none is in `.env.example`, so it
cannot become permanent deployment configuration. It is never printed, logged, or placed in an
error.
- **It writes no password hash of its own.** Hashing, salting, normalization, and the 8–128 bound are
the identity system's, applied through the same sign-up path a browser uses — so a CLI-created
account can never diverge from a browser-created one.
- **It cannot become a standing superuser factory.** Ownership bootstrap is permanently single-use
and this command consumes the same sentinel; there is no `--force`, reset, or override. A second
run creates no owner, changes no password, and creates no stray identity.
- **It never takes over an account.** An existing usable identity is adopted — granted ownership with
its password untouched — and reported as an adoption. A password reset is never performed. An
identity that has no password at all is refused rather than made an owner nobody can sign in as,
which would consume the single use and leave the installation unusable.
- **Races cannot mint two owners.** Whole invocations are serialized by an advisory lock, on top of
the grant's own locked, single-use transaction.
- **Errors stay sanitized.** No SQL, connection string, driver detail, Better Auth response body, or
cause reaches the operator's terminal — only a stable code and a message describing what is still
true of the installation.
It deliberately does not solve creating *additional* users or owners. That is user administration,
which does not exist yet.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
+23
View File
@@ -328,6 +328,29 @@ The canonical template builds and mounts both artifacts through `parseModuleUiEx
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
A further suite (`apps/api/tests/initial-owner.integration.ts`, same harness) covers the initial
platform-owner bootstrap ([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)).
It deliberately does not stop at asserting rows: a fresh installation is bootstrapped and the
resulting account is then signed in through the exact browser-facing login transport, its principal
read from `GET /api/v1/principal`, and used to open a platform-owner-protected resource that stays
closed to an anonymous caller. Anything less would prove a user was written, not that an operator can
reach their dashboard.
Every scenario runs with `publicSignupEnabled: false` — the production posture — so nothing can
accidentally depend on the signup route a real deployment does not have, and one scenario asserts
that a freshly bootstrapped deployment still refuses `POST /api/auth/sign-up/email`. The suite also
pins the guard (a second run creates no owner, changes no password, and leaves no stray identity),
the 8–128 password bound and email validation coming from the identity system rather than a
CLI-specific policy, adoption of an existing identity without touching its password, refusal of a
credential-less row *without* consuming the single-use sentinel, a partial failure that stays
recoverable by rerunning, and five racing invocations yielding exactly one owner, one sentinel, and
one user row.
The command itself is exercised as a real child process — success and failure exit codes, the
password arriving on standard input and never in `argv`, `--password` refused, a missing
`DATABASE_URL`, and an unmigrated database refused rather than silently migrated — with assertions
that neither stdout nor stderr contains the password or the connection string.
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+1
View File
@@ -18,6 +18,7 @@
"doctor": "node scripts/run-doctor.ts",
"format": "biome format --write .",
"format:check": "biome format .",
"identity:create-initial-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/create-initial-owner.js",
"identity:schema:validate": "turbo run build && node core/dist/cli/validate-identity-schema.js",
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
"core/tests/module-storage.integration.ts",
"core/tests/module-delivery.integration.ts",
"apps/api/tests/api-http.integration.ts",
"apps/api/tests/initial-owner.integration.ts",
"apps/api/tests/module-database.integration.ts",
],
passWithNoTests: false,