feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c131543abf
commit
c8fa13dd6a
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user