Merge pull request 'feat: create the initial platform owner from an operator command' (#34) from phase-7b/initial-owner-bootstrap into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/34
This commit was merged in pull request #34.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./initial-owner.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { BootstrapIdentityCreator } from "../identity/bootstrap-identity.js";
|
||||
import { BootstrapIdentityError } from "../identity/bootstrap-identity.js";
|
||||
import type { IdentityDrizzleDatabase } from "../identity/database.js";
|
||||
import { ekklesiaCoreAccount, ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import { bootstrapPlatformOwner, PlatformOwnerBootstrapError } from "./bootstrap.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCorePlatformOwnerBootstrap } from "./schema.js";
|
||||
|
||||
export type InitialPlatformOwnerErrorCode =
|
||||
| "INITIAL_OWNER_ALREADY_COMPLETED"
|
||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||
| "INITIAL_OWNER_SCHEMA_UNAVAILABLE";
|
||||
|
||||
const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, string>> = Object.freeze(
|
||||
{
|
||||
INITIAL_OWNER_ALREADY_COMPLETED:
|
||||
"This installation already has a platform owner. Initial-owner bootstrap runs once and cannot run again.",
|
||||
INITIAL_OWNER_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
INITIAL_OWNER_IDENTITY_NOT_CREATED:
|
||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
INITIAL_OWNER_PASSWORD_TOO_SHORT: "The password is shorter than the identity system allows.",
|
||||
INITIAL_OWNER_SCHEMA_UNAVAILABLE:
|
||||
"The database is unreachable or has not been migrated. Apply migrations before creating the initial owner.",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Never carries the password, a hash, a connection string, SQL, a driver error, or a cause. The
|
||||
* messages are written to be read by an operator at a terminal, so each one states what happened
|
||||
* *and* what is still true about the installation — in particular whether bootstrap remains
|
||||
* available, which is the only fact that determines what they should do next.
|
||||
*/
|
||||
export class InitialPlatformOwnerError extends Error {
|
||||
readonly code: InitialPlatformOwnerErrorCode;
|
||||
|
||||
constructor(code: InitialPlatformOwnerErrorCode) {
|
||||
super(initialOwnerMessages[code]);
|
||||
this.name = "InitialPlatformOwnerError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI-level advisory key distinct from `bootstrap.ts`'s: taking the same one would deadlock this
|
||||
// operation against the grant it performs. This serializes whole invocations; that one serializes
|
||||
// the grant transaction itself, and both remain in force.
|
||||
const initialOwnerLockNamespace = 1_500_000_003;
|
||||
const initialOwnerLockKey = 1_500_000_004;
|
||||
|
||||
export interface CreateInitialPlatformOwnerDependencies {
|
||||
readonly authorizationDb: AuthorizationDrizzleDatabase;
|
||||
readonly identityDb: IdentityDrizzleDatabase;
|
||||
readonly createIdentity: BootstrapIdentityCreator;
|
||||
/** Injected so a partial failure — identity created, ownership not granted — can be exercised.
|
||||
* Production composition leaves it unset and the real single-use bootstrap is used. */
|
||||
readonly grantOwnership?: (userId: EkklesiaUserId) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateInitialPlatformOwnerInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
/** The display name the identity model requires. */
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface InitialPlatformOwnerResult {
|
||||
readonly email: string;
|
||||
readonly userId: EkklesiaUserId;
|
||||
/** `created` when this call made the identity; `adopted` when one already existed for that
|
||||
* address and only ownership was granted. Reported so an operator is never told something was
|
||||
* created when it was not. */
|
||||
readonly identity: "adopted" | "created";
|
||||
}
|
||||
|
||||
interface ExistingIdentity {
|
||||
readonly userId: string;
|
||||
readonly hasCredential: boolean;
|
||||
/** As the identity system actually stored it, which is not necessarily as the operator typed it. */
|
||||
readonly storedEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an identity by email, case-insensitively.
|
||||
*
|
||||
* Deliberately not an exact match on the caller's spelling: Better Auth lowercases the address it
|
||||
* stores (verified against the pinned release — `[email protected]` persists as
|
||||
* `[email protected]`), so an exact comparison would fail to find the very account this operation
|
||||
* had just created and would report it missing.
|
||||
*/
|
||||
async function findIdentityByEmail(
|
||||
db: IdentityDrizzleDatabase,
|
||||
email: string,
|
||||
): Promise<ExistingIdentity | undefined> {
|
||||
const rows = await db
|
||||
.select({ email: ekklesiaCoreUser.email, id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(sql`lower(${ekklesiaCoreUser.email}) = ${email.trim().toLowerCase()}`)
|
||||
.limit(1);
|
||||
const found = rows[0];
|
||||
if (!found) {
|
||||
return undefined;
|
||||
}
|
||||
// A row in `user` alone is not an account anyone can sign in as — the credential lives in
|
||||
// `account`. The distinction matters: granting ownership to a passwordless row would consume the
|
||||
// one-time bootstrap on an identity nobody can authenticate as, leaving the installation with an
|
||||
// owner that cannot log in and no second chance.
|
||||
const credentials = await db
|
||||
.select({ id: ekklesiaCoreAccount.id })
|
||||
.from(ekklesiaCoreAccount)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreAccount.userId, found.id),
|
||||
eq(ekklesiaCoreAccount.providerId, "credential"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||
}
|
||||
|
||||
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||
if (error instanceof BootstrapIdentityError) {
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_EMAIL_INVALID") {
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
}
|
||||
return new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation: one identity, then the single-use
|
||||
* ownership grant.
|
||||
*
|
||||
* ## Why the identity is verified rather than trusted
|
||||
*
|
||||
* Better Auth's sign-up returns a **fabricated success** when the address is already registered —
|
||||
* a user object carrying an id that was never persisted, with the database left unchanged (verified
|
||||
* against the pinned release). On a public endpoint that is correct: it refuses to tell an
|
||||
* anonymous caller which addresses exist. Here it is a trap, because a caller that believed the
|
||||
* response would report an owner it never created and then grant ownership to an id that does not
|
||||
* exist. So the identity is always read back from the database, and that read — not the sign-up
|
||||
* response — is what the grant uses.
|
||||
*
|
||||
* ## Why an existing identity is adopted rather than refused or overwritten
|
||||
*
|
||||
* If the address already has a usable account and bootstrap is still open, ownership is granted to
|
||||
* it and **its password is left completely alone**. This is precisely the recovery path for the one
|
||||
* partial failure this operation can have: sign-up commits through Better Auth's own connection and
|
||||
* the grant runs in a separate transaction, so a crash between them leaves an identity with no
|
||||
* ownership. Refusing would strand a fresh installation with no way forward but manual SQL; a
|
||||
* password reset would be a silent takeover of an account the operator may not own. Adoption
|
||||
* changes no credential, and it is reported as `adopted` so nobody is told an account was created
|
||||
* when it was not.
|
||||
*
|
||||
* That is safe here specifically because production has **no** other way to create an identity —
|
||||
* public signup cannot be enabled at all — so an account present on a fresh installation was put
|
||||
* there by someone who already had database access.
|
||||
*
|
||||
* ## Ordering
|
||||
*
|
||||
* The bootstrap sentinel is checked before an identity is created, so a closed bootstrap never
|
||||
* leaves a stray account behind. That check is an early exit, not the authority: `bootstrapPlatformOwner`
|
||||
* re-checks it inside its own locked transaction, which is what actually makes the grant single-use.
|
||||
*/
|
||||
export async function createInitialPlatformOwner(
|
||||
dependencies: CreateInitialPlatformOwnerDependencies,
|
||||
input: CreateInitialPlatformOwnerInput,
|
||||
): Promise<InitialPlatformOwnerResult> {
|
||||
const email = input.email.trim();
|
||||
if (email.length === 0) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_EMAIL_INVALID");
|
||||
}
|
||||
|
||||
// Serializes entire invocations against each other. Without it two operators racing on a fresh
|
||||
// installation would both pass the sentinel check and both create an identity, and only then
|
||||
// would one lose the grant — leaving a stray account that can sign in and owns nothing. The grant
|
||||
// alone was already safe; this makes the whole command safe.
|
||||
return dependencies.authorizationDb.transaction(async (lockTx) => {
|
||||
await lockTx.execute(
|
||||
sql`select pg_advisory_xact_lock(${initialOwnerLockNamespace}, ${initialOwnerLockKey})`,
|
||||
);
|
||||
|
||||
let alreadyConsumed: boolean;
|
||||
try {
|
||||
const consumed = await lockTx
|
||||
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
||||
.from(ekklesiaCorePlatformOwnerBootstrap)
|
||||
.limit(1);
|
||||
alreadyConsumed = consumed.length > 0;
|
||||
} catch {
|
||||
// An unreachable database and a database without the authorization tables are the same
|
||||
// situation to an operator running this on a fresh server: the deployment step before this
|
||||
// one has not happened. Never migrated implicitly — see ADR 0035.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (alreadyConsumed) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
|
||||
let existing: ExistingIdentity | undefined;
|
||||
try {
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email);
|
||||
} catch {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_SCHEMA_UNAVAILABLE");
|
||||
}
|
||||
if (existing && !existing.hasCredential) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_UNUSABLE");
|
||||
}
|
||||
|
||||
// Captured before the read-back below, which cannot distinguish the two: an identity this call
|
||||
// just created and one it adopted both come back with a credential.
|
||||
const identity: "adopted" | "created" = existing ? "adopted" : "created";
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
await dependencies.createIdentity({
|
||||
email,
|
||||
name: input.name,
|
||||
password: input.password,
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateIdentityCreationError(error);
|
||||
}
|
||||
existing = await findIdentityByEmail(dependencies.identityDb, email).catch(() => undefined);
|
||||
if (!existing) {
|
||||
// Reached when sign-up reported success without persisting anything.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
}
|
||||
|
||||
const parsedUserId = safeParseEkklesiaUserId(existing.userId);
|
||||
if (!parsedUserId.success) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||
}
|
||||
|
||||
try {
|
||||
const grant =
|
||||
dependencies.grantOwnership ??
|
||||
((userId: EkklesiaUserId) =>
|
||||
bootstrapPlatformOwner(dependencies.authorizationDb, { userId }));
|
||||
await grant(parsedUserId.data);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PlatformOwnerBootstrapError &&
|
||||
error.code === "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
) {
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_ALREADY_COMPLETED");
|
||||
}
|
||||
// The identity is real and remains usable; the sentinel rolled back with the grant, so
|
||||
// re-running this command with the same address adopts it and finishes.
|
||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_OWNERSHIP_GRANT_FAILED");
|
||||
}
|
||||
|
||||
// The stored address, never the operator's spelling: the identity system lowercases what it
|
||||
// persists, and telling someone to sign in as `[email protected]` when the account is
|
||||
// `[email protected]` invites them to conclude the command failed.
|
||||
return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createInitialPlatformOwner,
|
||||
InitialPlatformOwnerError,
|
||||
} from "../authorization/index.js";
|
||||
import {
|
||||
createBootstrapIdentityCreator,
|
||||
createIdentityDrizzleDatabase,
|
||||
} from "../identity/index.js";
|
||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||
|
||||
/**
|
||||
* Creates the first platform owner of a fresh installation — one identity, then the single-use
|
||||
* ownership grant — so a freshly deployed Ekklesia has a supported way to get its first
|
||||
* administrator (ADR 0035).
|
||||
*
|
||||
* This exists because the login page has nothing to log in to on a new installation. Public signup
|
||||
* is disabled in every environment and production cannot enable it at all, which is correct and is
|
||||
* not changed here; the consequence was simply that no supported path created the first account.
|
||||
* This is that path, and it is deliberately the same shape as `pnpm authorization:bootstrap-owner`
|
||||
* and `pnpm modules:enable`: explicit, operator-invoked, never HTTP-exposed, never inferred, and
|
||||
* reachable only by someone who already holds the database credentials.
|
||||
*
|
||||
* It creates no HTTP route, starts no server, and applies no migration.
|
||||
*/
|
||||
class InitialOwnerCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "InitialOwnerCliError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm identity:create-initial-owner -- --email <address> [--name <display name>]\n" +
|
||||
"The password is never taken from the command line. It is prompted for when a terminal is\n" +
|
||||
"attached, and otherwise read from standard input:\n" +
|
||||
" printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email [email protected]";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly email: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and
|
||||
* `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let email: string | undefined;
|
||||
let name: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--email") {
|
||||
email = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--name") {
|
||||
name = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--password" || flag?.startsWith("--password=") === true) {
|
||||
// Refused explicitly rather than ignored, so an operator who tries it learns why instead of
|
||||
// wondering why their password was not used.
|
||||
throw new InitialOwnerCliError(
|
||||
"--password is not supported: a command-line password is visible in shell history and in the process list.\n" +
|
||||
usage,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (email === undefined || email.trim().length === 0) {
|
||||
throw new InitialOwnerCliError(usage);
|
||||
}
|
||||
return name === undefined ? { email } : { email, name };
|
||||
}
|
||||
|
||||
/** Reads one line without echoing it. No dependency is added for this: the command needs exactly
|
||||
* one hidden field, and Node's own raw-mode stdin is enough. */
|
||||
function promptHidden(prompt: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const input = process.stdin;
|
||||
process.stdout.write(prompt);
|
||||
input.setRawMode(true);
|
||||
input.resume();
|
||||
input.setEncoding("utf8");
|
||||
let value = "";
|
||||
const finish = (settle: () => void): void => {
|
||||
input.removeListener("data", onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
process.stdout.write("\n");
|
||||
settle();
|
||||
};
|
||||
const onData = (chunk: string): void => {
|
||||
for (const character of chunk) {
|
||||
if (character === "\r" || character === "\n" || character === "\u0004") {
|
||||
finish(() => resolve(value));
|
||||
return;
|
||||
}
|
||||
if (character === "\u0003") {
|
||||
finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made.")));
|
||||
return;
|
||||
}
|
||||
// Backspace and delete, so a mistyped character can be corrected in a field nobody can see.
|
||||
if (character === "\u007f" || character === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
value += character;
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reads standard input to end, for automated deployment. */
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let buffer = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(buffer));
|
||||
process.stdin.on("error", () =>
|
||||
reject(new InitialOwnerCliError("Standard input could not be read.")),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains the password without it ever appearing in `argv`, an environment variable, or the
|
||||
* terminal.
|
||||
*
|
||||
* A command-line password is visible in shell history and to every other process on the machine via
|
||||
* the process list, and an environment variable tends to become permanent — a bootstrap password
|
||||
* belongs to one invocation, not to the deployment's configuration, so this command deliberately
|
||||
* reads no environment variable for it and none is added to `.env.example`.
|
||||
*
|
||||
* Interactive use asks twice and compares, because the field is invisible and a typo would create
|
||||
* an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed.
|
||||
*/
|
||||
async function readPassword(): Promise<string> {
|
||||
if (process.stdin.isTTY === true) {
|
||||
const password = await promptHidden("Password: ");
|
||||
const confirmation = await promptHidden("Confirm password: ");
|
||||
if (password !== confirmation) {
|
||||
throw new InitialOwnerCliError("The passwords did not match. No changes were made.");
|
||||
}
|
||||
return password;
|
||||
}
|
||||
// A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced
|
||||
// file all behave the same; nothing else is trimmed, because leading and trailing spaces are
|
||||
// legitimate password characters.
|
||||
const piped = await readStdin();
|
||||
return piped.replace(/\r?\n$/u, "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { email, name } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new InitialOwnerCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
const password = await readPassword();
|
||||
if (password.length === 0) {
|
||||
throw new InitialOwnerCliError("A password is required. No changes were made.");
|
||||
}
|
||||
|
||||
// Asserted before any connection is constructed, so an unsafe production transport never opens a
|
||||
// client at all — identical to every other Core executable.
|
||||
const transport = resolveExecutableDatabaseTransport(databaseUrl);
|
||||
const databaseOptions = {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 30_000,
|
||||
transport,
|
||||
};
|
||||
const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions);
|
||||
try {
|
||||
const result = await createInitialPlatformOwner(
|
||||
{
|
||||
authorizationDb: authorization.db,
|
||||
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||
identityDb: identity.db,
|
||||
},
|
||||
{ email, name: name ?? email, password },
|
||||
);
|
||||
|
||||
// Never the password, its length, a hash, a session token, a user id, or the database URL.
|
||||
console.log(
|
||||
result.identity === "created"
|
||||
? "Initial platform owner created successfully."
|
||||
: "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.",
|
||||
);
|
||||
console.log(`Email: ${result.email}`);
|
||||
console.log("You can now sign in to the Ekklesia dashboard.");
|
||||
} finally {
|
||||
await Promise.all([
|
||||
authorization.close().catch(() => undefined),
|
||||
identity.close().catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function reportInitialOwnerCliError(error: unknown): void {
|
||||
if (error instanceof InitialOwnerCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof InitialPlatformOwnerError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
// Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or
|
||||
// the password itself if it were ever interpolated into a message upstream.
|
||||
console.error("Initial platform owner creation failed unexpectedly.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportInitialOwnerCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
|
||||
* operator-invoked initial-owner bootstrap and nothing else.
|
||||
*
|
||||
* ## Why this is not `createIdentityRuntime`
|
||||
*
|
||||
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
|
||||
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
|
||||
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
|
||||
* `auth.api.signUpEmail` on the server's own instance is refused with
|
||||
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
|
||||
* assumed. So a fresh production installation has no way to create the one identity it needs.
|
||||
*
|
||||
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
|
||||
* it is an in-process library call reachable only by an operator who already holds the database
|
||||
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
|
||||
* it. Public signup remains disabled in every environment.
|
||||
*
|
||||
* ## Why the library path rather than writing rows
|
||||
*
|
||||
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
|
||||
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
|
||||
* and a password hash would be a second, divergent identity implementation — and the failure mode
|
||||
* is an account that exists but cannot sign in.
|
||||
*
|
||||
* ## Prerequisites this deliberately does not have
|
||||
*
|
||||
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
|
||||
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
|
||||
* through a *separately constructed* instance carrying a different secret — password hashes are
|
||||
* salted per account and independent of the secret. That matters operationally, because it means an
|
||||
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
|
||||
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
|
||||
* configured rather than silently defaulted; this instance never mints a session, so nothing is
|
||||
* ever signed with it and it is discarded when the process exits.
|
||||
*/
|
||||
export type BootstrapIdentityErrorCode =
|
||||
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
|
||||
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
|
||||
|
||||
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
|
||||
Object.freeze({
|
||||
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
|
||||
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
|
||||
"The password is shorter than the identity system allows.",
|
||||
});
|
||||
|
||||
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
|
||||
export class BootstrapIdentityError extends Error {
|
||||
readonly code: BootstrapIdentityErrorCode;
|
||||
|
||||
constructor(code: BootstrapIdentityErrorCode) {
|
||||
super(bootstrapIdentityMessages[code]);
|
||||
this.name = "BootstrapIdentityError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapIdentityInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Creates one credential identity. Resolving says only that the identity system reported success —
|
||||
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
|
||||
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
|
||||
|
||||
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
|
||||
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
|
||||
* creation failure so no library internal, response body, or cause is ever surfaced. */
|
||||
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
|
||||
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
|
||||
if (code === "PASSWORD_TOO_SHORT") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (code === "PASSWORD_TOO_LONG") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (code === "VALIDATION_ERROR") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
|
||||
}
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
|
||||
}
|
||||
|
||||
export function createBootstrapIdentityCreator(
|
||||
db: IdentityDrizzleDatabase,
|
||||
): BootstrapIdentityCreator {
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
|
||||
emailAndPassword: {
|
||||
// No session is minted here. The operator signs in through the ordinary browser transport
|
||||
// afterwards, which is also what proves the account really works.
|
||||
autoSignIn: false,
|
||||
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
|
||||
disableSignUp: false,
|
||||
enabled: true,
|
||||
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
|
||||
// the browser would later reject, or reject one it would accept.
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
logger: {
|
||||
// Better Auth's own log call sites interpolate raw input and exceptions; during account
|
||||
// creation that input includes a password. Nothing it wants to log is ever emitted.
|
||||
disabled: true,
|
||||
},
|
||||
secret: randomBytes(32).toString("base64url"),
|
||||
});
|
||||
|
||||
return async (input: BootstrapIdentityInput): Promise<void> => {
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: input.email, name: input.name, password: input.password },
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateBootstrapIdentityError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./bootstrap-identity.js";
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
describe("create-initial-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("reads no environment variable for the password", async () => {
|
||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||
// the environment, so nothing can make it permanent deployment configuration. Asserted against
|
||||
// the source rather than behaviour, because the absence of a lookup is what matters.
|
||||
const source = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(new URL("../src/cli/create-initial-owner.ts", import.meta.url), "utf8"),
|
||||
);
|
||||
|
||||
const environmentReads = [...source.matchAll(/process\.env\.([A-Z_]+)/gu)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
expect(environmentReads).toEqual(["DATABASE_URL"]);
|
||||
// No dynamic lookup either, which a named-property scan alone would miss.
|
||||
expect(source).not.toMatch(/process\.env\[/u);
|
||||
// And a password supplied on the command line is refused rather than quietly honoured.
|
||||
expect(source).toContain("--password is not supported");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 [email protected]
|
||||
# Password: (typed invisibly)
|
||||
# Confirm password:
|
||||
# Initial platform owner created successfully.
|
||||
# Email: [email protected]
|
||||
# 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 [email protected]
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user