From c8fa13dd6a1df62db1dcd1f6f4ce5fd46ddb2823 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 23 Aug 2026 22:32:53 -0500 Subject: [PATCH] feat: create the initial platform owner from an operator command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 owner@example.org 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 --- README.md | 45 +- apps/api/tests/initial-owner.integration.ts | 687 ++++++++++++++++++ core/src/authorization/index.ts | 1 + core/src/authorization/initial-owner.ts | 278 +++++++ core/src/cli/create-initial-owner.ts | 233 ++++++ core/src/identity/bootstrap-identity.ts | 132 ++++ core/src/identity/index.ts | 1 + core/tests/cli-create-initial-owner.test.ts | 40 + .../0035-initial-platform-owner-bootstrap.md | 182 +++++ docs/architecture/decisions/README.md | 1 + docs/development/authorization.md | 29 +- docs/development/identity.md | 87 ++- docs/development/security.md | 38 + docs/development/testing.md | 23 + package.json | 1 + vitest.migrations.integration.config.ts | 1 + 16 files changed, 1767 insertions(+), 12 deletions(-) create mode 100644 apps/api/tests/initial-owner.integration.ts create mode 100644 core/src/authorization/initial-owner.ts create mode 100644 core/src/cli/create-initial-owner.ts create mode 100644 core/src/identity/bootstrap-identity.ts create mode 100644 core/tests/cli-create-initial-owner.test.ts create mode 100644 docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md diff --git a/README.md b/README.md index be71e20..9ea1a6c 100644 --- a/README.md +++ b/README.md @@ -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 owner@example.org +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 owner@example.org +``` + +**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 diff --git a/apps/api/tests/initial-owner.integration.ts b/apps/api/tests/initial-owner.integration.ts new file mode 100644 index 0000000..7a234fd --- /dev/null +++ b/apps/api/tests/initial-owner.integration.ts @@ -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(); +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 { + const client = new Client({ connectionString: baseDatabaseUrl() }); + await client.connect(); + return client; +} + +async function safeQuery( + client: Client, + text: string, + values: readonly unknown[] = [], +): Promise { + try { + const result = await client.query(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 { + 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 { + 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 { + const url = await createDisposableDatabase(label); + await applyCoreMigrations(url); + return url; +} + +interface BootstrapHandles { + readonly run: ( + email: string, + password?: string, + name?: string, + ) => ReturnType; + readonly close: () => Promise; +} + +/** 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 { + 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 { + const environment: Record = { + ...(process.env as Record), + 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 { + 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>; + 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", "ghost@example.test"], + ); + } finally { + await client.end().catch(() => undefined); + } + + const bootstrap = bootstrapAgainst(url); + try { + await expect(bootstrap.run("ghost@example.test")).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", "owner@example.test", "--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", "owner@example.test"], + "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", "owner@example.test"], + 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); + }); + }); +}); diff --git a/core/src/authorization/index.ts b/core/src/authorization/index.ts index 272b3ad..ef12925 100644 --- a/core/src/authorization/index.ts +++ b/core/src/authorization/index.ts @@ -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"; diff --git a/core/src/authorization/initial-owner.ts b/core/src/authorization/initial-owner.ts new file mode 100644 index 0000000..3e4472f --- /dev/null +++ b/core/src/authorization/initial-owner.ts @@ -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> = 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; +} + +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 — `MiXeD@Example.ORG` persists as + * `mixed@example.org`), 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 { + 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 { + 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 `Owner@Example.ORG` when the account is + // `owner@example.org` invites them to conclude the command failed. + return Object.freeze({ email: existing.storedEmail, identity, userId: parsedUserId.data }); + }); +} diff --git a/core/src/cli/create-initial-owner.ts b/core/src/cli/create-initial-owner.ts new file mode 100644 index 0000000..c2ebabd --- /dev/null +++ b/core/src/cli/create-initial-owner.ts @@ -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
[--name ]\n" + + "The password is never taken from the command line. It is prompted for when a terminal is\n" + + "attached, and otherwise read from standard input:\n" + + " printf '%s' \"$PASSWORD\" | pnpm identity:create-initial-owner -- --email owner@example.org"; + +interface ParsedArgs { + readonly email: string; + readonly name?: string; +} + +/** A minimal, hand-rolled flag parser, exactly like `bootstrap-platform-owner.ts` and + * `set-module-enabled.ts`. There is deliberately no `--password`: see `readPassword`. */ +function parseArgs(argv: readonly string[]): ParsedArgs { + let email: string | undefined; + let name: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === "--email") { + email = argv[index + 1]; + index += 1; + } else if (flag === "--name") { + name = argv[index + 1]; + index += 1; + } else if (flag === "--password" || flag?.startsWith("--password=") === true) { + // Refused explicitly rather than ignored, so an operator who tries it learns why instead of + // wondering why their password was not used. + throw new InitialOwnerCliError( + "--password is not supported: a command-line password is visible in shell history and in the process list.\n" + + usage, + ); + } + } + if (email === undefined || email.trim().length === 0) { + throw new InitialOwnerCliError(usage); + } + return name === undefined ? { email } : { email, name }; +} + +/** Reads one line without echoing it. No dependency is added for this: the command needs exactly + * one hidden field, and Node's own raw-mode stdin is enough. */ +function promptHidden(prompt: string): Promise { + return new Promise((resolve, reject) => { + const input = process.stdin; + process.stdout.write(prompt); + input.setRawMode(true); + input.resume(); + input.setEncoding("utf8"); + let value = ""; + const finish = (settle: () => void): void => { + input.removeListener("data", onData); + input.setRawMode(false); + input.pause(); + process.stdout.write("\n"); + settle(); + }; + const onData = (chunk: string): void => { + for (const character of chunk) { + if (character === "\r" || character === "\n" || character === "\u0004") { + finish(() => resolve(value)); + return; + } + if (character === "\u0003") { + finish(() => reject(new InitialOwnerCliError("Cancelled. No changes were made."))); + return; + } + // Backspace and delete, so a mistyped character can be corrected in a field nobody can see. + if (character === "\u007f" || character === "\b") { + value = value.slice(0, -1); + continue; + } + value += character; + } + }; + input.on("data", onData); + }); +} + +/** Reads standard input to end, for automated deployment. */ +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk: string) => { + buffer += chunk; + }); + process.stdin.on("end", () => resolve(buffer)); + process.stdin.on("error", () => + reject(new InitialOwnerCliError("Standard input could not be read.")), + ); + }); +} + +/** + * Obtains the password without it ever appearing in `argv`, an environment variable, or the + * terminal. + * + * A command-line password is visible in shell history and to every other process on the machine via + * the process list, and an environment variable tends to become permanent — a bootstrap password + * belongs to one invocation, not to the deployment's configuration, so this command deliberately + * reads no environment variable for it and none is added to `.env.example`. + * + * Interactive use asks twice and compares, because the field is invisible and a typo would create + * an owner nobody can sign in as, on an installation whose one bootstrap has now been consumed. + */ +async function readPassword(): Promise { + if (process.stdin.isTTY === true) { + const password = await promptHidden("Password: "); + const confirmation = await promptHidden("Confirm password: "); + if (password !== confirmation) { + throw new InitialOwnerCliError("The passwords did not match. No changes were made."); + } + return password; + } + // A single trailing newline is stripped so `printf '%s\n'`, a here-string, and an editor-produced + // file all behave the same; nothing else is trimmed, because leading and trailing spaces are + // legitimate password characters. + const piped = await readStdin(); + return piped.replace(/\r?\n$/u, ""); +} + +async function main(): Promise { + const { email, name } = parseArgs(process.argv.slice(2)); + + // Read only when this command actually executes — never merely by importing this module. + const databaseUrl = process.env.DATABASE_URL; + if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) { + throw new InitialOwnerCliError("DATABASE_URL is required."); + } + + const password = await readPassword(); + if (password.length === 0) { + throw new InitialOwnerCliError("A password is required. No changes were made."); + } + + // Asserted before any connection is constructed, so an unsafe production transport never opens a + // client at all — identical to every other Core executable. + const transport = resolveExecutableDatabaseTransport(databaseUrl); + const databaseOptions = { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 30_000, + transport, + }; + const authorization = createAuthorizationDrizzleDatabase(databaseUrl, databaseOptions); + const identity = createIdentityDrizzleDatabase(databaseUrl, databaseOptions); + try { + const result = await createInitialPlatformOwner( + { + authorizationDb: authorization.db, + createIdentity: createBootstrapIdentityCreator(identity.db), + identityDb: identity.db, + }, + { email, name: name ?? email, password }, + ); + + // Never the password, its length, a hash, a session token, a user id, or the database URL. + console.log( + result.identity === "created" + ? "Initial platform owner created successfully." + : "An account already existed for that address; platform ownership was granted to it and its password was left unchanged.", + ); + console.log(`Email: ${result.email}`); + console.log("You can now sign in to the Ekklesia dashboard."); + } finally { + await Promise.all([ + authorization.close().catch(() => undefined), + identity.close().catch(() => undefined), + ]); + } +} + +function reportInitialOwnerCliError(error: unknown): void { + if (error instanceof InitialOwnerCliError) { + console.error(error.message); + return; + } + if (error instanceof InitialPlatformOwnerError) { + console.error(`[${error.code}] ${error.message}`); + return; + } + // Deliberately generic: the caught value may carry SQL, a connection string, a driver detail, or + // the password itself if it were ever interpolated into a message upstream. + console.error("Initial platform owner creation failed unexpectedly."); +} + +function isDirectExecution(): boolean { + const entrypoint = process.argv[1]; + return ( + entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href + ); +} + +if (isDirectExecution()) { + main().catch((error: unknown) => { + reportInitialOwnerCliError(error); + process.exitCode = 1; + }); +} diff --git a/core/src/identity/bootstrap-identity.ts b/core/src/identity/bootstrap-identity.ts new file mode 100644 index 0000000..d9b3c36 --- /dev/null +++ b/core/src/identity/bootstrap-identity.ts @@ -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> = + 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; + +/** 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 => { + try { + await auth.api.signUpEmail({ + body: { email: input.email, name: input.name, password: input.password }, + }); + } catch (error) { + throw translateBootstrapIdentityError(error); + } + }; +} diff --git a/core/src/identity/index.ts b/core/src/identity/index.ts index b943617..458c7dd 100644 --- a/core/src/identity/index.ts +++ b/core/src/identity/index.ts @@ -1,3 +1,4 @@ +export * from "./bootstrap-identity.js"; export * from "./client-address.js"; export * from "./config.js"; export * from "./database.js"; diff --git a/core/tests/cli-create-initial-owner.test.ts b/core/tests/cli-create-initial-owner.test.ts new file mode 100644 index 0000000..ab8b3db --- /dev/null +++ b/core/tests/cli-create-initial-owner.test.ts @@ -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"); + }); +}); diff --git a/docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md b/docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md new file mode 100644 index 0000000..ef7d9c1 --- /dev/null +++ b/docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md @@ -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 owner@example.org +``` + +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 owner@example.org +``` + +`--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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 0bc99d7..c627d10 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/development/authorization.md b/docs/development/authorization.md index f115c08..7e00f67 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 owner@example.org +``` + +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 diff --git a/docs/development/identity.md b/docs/development/identity.md index 4900c46..8c6fea7 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -262,6 +262,87 @@ roles/groups/teams/tenants model exists. Core's authorization foundation (platform owners, explicit grants, and an immutable permission registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants -an existing identity; it does not create one. Because production signup is prohibited and no -operator enrollment command exists, creating the initial production identity needed for owner -bootstrap remains an explicit operational gap. +an existing identity; it does not create one. Creating the *first* identity on a fresh installation +is what `pnpm identity:create-initial-owner` is for — see below. + +## Creating the first platform owner + +A fresh installation has a login page and nothing to log in to. Public registration is disabled by +default and production cannot enable it at all, so one explicit, operator-invoked command creates +the initial identity and grants it platform ownership +([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)): + +```bash +pnpm migrations:apply # the schema must already exist; this command never migrates +DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owner@example.org +# Password: (typed invisibly) +# Confirm password: +# Initial platform owner created successfully. +# Email: owner@example.org +# You can now sign in to the Ekklesia dashboard. +``` + +`--name` is optional and defaults to the email address; the identity model requires a display name, +and asking for one that nobody can edit yet would be a question without a purpose. + +### The password never becomes an argument or a variable + +It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an +explanation**, because a command-line argument is visible in shell history and to every process on +the machine through the process list. For automated deployment the password is read from standard +input: + +```bash +printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email owner@example.org +``` + +**No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used +once to create an account; it is not deployment configuration, and an environment variable is +precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash, +a session token, or the database URL. + +### Required environment + +`DATABASE_URL`, and the same optional transport variables every other Core command honours +(`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity +secret, trusted origins, or a public base URL: none is needed to create an account, so the first +owner can be created on a freshly provisioned server before TLS or DNS exist. + +### Password and email rules are the identity system's own + +The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up +path a browser would use, not by a separate CLI policy that could drift from it. The address is +normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports +the stored address, so the one you are told to sign in with is the one that exists. + +### It runs once + +Ownership bootstrap is permanently single-use (see +[Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and: + +- creates no second owner, +- changes no existing password, +- creates no stray identity, even when a different address is supplied. + +### Rerun and recovery + +| situation | behaviour | +| --- | --- | +| Bootstrap already completed | Refused. Nothing changes. | +| Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. | +| A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. | +| Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. | +| Database unmigrated or unreachable | Refused with a clear message. The command never migrates. | + +Nothing is deleted automatically on failure: removing an account that was created successfully, on +the strength of a later error, would destroy something an operator may need. + +### This is not signup + +There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no +temporary setup route, and no setup token. The login page stays login-only, and public registration +stays disabled in every environment — an integration test asserts that a freshly bootstrapped +deployment still refuses `POST /api/auth/sign-up/email`. + +Creating additional users or owners is not this command's job. That belongs to user administration, +which does not exist yet. diff --git a/docs/development/security.md b/docs/development/security.md index b4f6720..ebfd00d 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 diff --git a/docs/development/testing.md b/docs/development/testing.md index 102d8a9..2eb5069 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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: diff --git a/package.json b/package.json index 983db79..e1a7ea0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 46cc925..d1bf510 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -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,