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,132 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import { identityAdapterSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Creates a first credential identity **through Better Auth's own sign-up path**, for the
|
||||
* operator-invoked initial-owner bootstrap and nothing else.
|
||||
*
|
||||
* ## Why this is not `createIdentityRuntime`
|
||||
*
|
||||
* The server runtime deliberately refuses to create accounts: `disableSignUp` is derived from
|
||||
* `IdentityConfig.publicSignupEnabled`, which production cannot enable at all
|
||||
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). That is correct and is not relaxed here — calling
|
||||
* `auth.api.signUpEmail` on the server's own instance is refused with
|
||||
* `EMAIL_PASSWORD_SIGN_UP_DISABLED`, which was verified against the pinned release rather than
|
||||
* assumed. So a fresh production installation has no way to create the one identity it needs.
|
||||
*
|
||||
* This is that way, and its safety comes from *where it can be invoked*, not from a weaker policy:
|
||||
* it is an in-process library call reachable only by an operator who already holds the database
|
||||
* credentials, it serves no HTTP route, and it is mounted nowhere. Nothing in `apps/api` imports
|
||||
* it. Public signup remains disabled in every environment.
|
||||
*
|
||||
* ## Why the library path rather than writing rows
|
||||
*
|
||||
* Hashing, salting, email normalization, the `user`/`account` pair, and the 8–128 password bounds
|
||||
* are all Better Auth's, applied exactly as they are for a browser sign-up. Hand-writing a user row
|
||||
* and a password hash would be a second, divergent identity implementation — and the failure mode
|
||||
* is an account that exists but cannot sign in.
|
||||
*
|
||||
* ## Prerequisites this deliberately does not have
|
||||
*
|
||||
* No `baseURL`, no trusted origins, and no configured identity secret. All three were tested
|
||||
* against the pinned release: sign-up succeeds without them, and the resulting account signs in
|
||||
* through a *separately constructed* instance carrying a different secret — password hashes are
|
||||
* salted per account and independent of the secret. That matters operationally, because it means an
|
||||
* operator can create the first owner on a freshly provisioned server before DNS, TLS, or the
|
||||
* public origin exist. The ephemeral secret below exists only so the instance is explicitly
|
||||
* configured rather than silently defaulted; this instance never mints a session, so nothing is
|
||||
* ever signed with it and it is discarded when the process exits.
|
||||
*/
|
||||
export type BootstrapIdentityErrorCode =
|
||||
| "BOOTSTRAP_IDENTITY_CREATION_FAILED"
|
||||
| "BOOTSTRAP_IDENTITY_EMAIL_INVALID"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG"
|
||||
| "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT";
|
||||
|
||||
const bootstrapIdentityMessages: Readonly<Record<BootstrapIdentityErrorCode, string>> =
|
||||
Object.freeze({
|
||||
BOOTSTRAP_IDENTITY_CREATION_FAILED: "The initial identity could not be created.",
|
||||
BOOTSTRAP_IDENTITY_EMAIL_INVALID: "The email address is not a valid address.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT:
|
||||
"The password is shorter than the identity system allows.",
|
||||
});
|
||||
|
||||
/** Never carries the password, the email, a hash, a Better Auth response body, or a cause. */
|
||||
export class BootstrapIdentityError extends Error {
|
||||
readonly code: BootstrapIdentityErrorCode;
|
||||
|
||||
constructor(code: BootstrapIdentityErrorCode) {
|
||||
super(bootstrapIdentityMessages[code]);
|
||||
this.name = "BootstrapIdentityError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapIdentityInput {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Creates one credential identity. Resolving says only that the identity system reported success —
|
||||
* never that a row exists, which the caller must confirm for itself (see `createInitialPlatformOwner`). */
|
||||
export type BootstrapIdentityCreator = (input: BootstrapIdentityInput) => Promise<void>;
|
||||
|
||||
/** Better Auth reports failures as an `APIError` carrying a `body.code`. Only the codes that are
|
||||
* genuinely the operator's to act on are distinguished; everything else collapses into the generic
|
||||
* creation failure so no library internal, response body, or cause is ever surfaced. */
|
||||
function translateBootstrapIdentityError(error: unknown): BootstrapIdentityError {
|
||||
const code = (error as { body?: { code?: unknown } } | null)?.body?.code;
|
||||
if (code === "PASSWORD_TOO_SHORT") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT");
|
||||
}
|
||||
if (code === "PASSWORD_TOO_LONG") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_PASSWORD_TOO_LONG");
|
||||
}
|
||||
if (code === "VALIDATION_ERROR") {
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_EMAIL_INVALID");
|
||||
}
|
||||
return new BootstrapIdentityError("BOOTSTRAP_IDENTITY_CREATION_FAILED");
|
||||
}
|
||||
|
||||
export function createBootstrapIdentityCreator(
|
||||
db: IdentityDrizzleDatabase,
|
||||
): BootstrapIdentityCreator {
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg", schema: identityAdapterSchema }),
|
||||
emailAndPassword: {
|
||||
// No session is minted here. The operator signs in through the ordinary browser transport
|
||||
// afterwards, which is also what proves the account really works.
|
||||
autoSignIn: false,
|
||||
// Enabled on *this* instance only. See the note above: this object serves no HTTP route.
|
||||
disableSignUp: false,
|
||||
enabled: true,
|
||||
// The identical bounds `createIdentityRuntime` pins, so the CLI can never accept a password
|
||||
// the browser would later reject, or reject one it would accept.
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
},
|
||||
logger: {
|
||||
// Better Auth's own log call sites interpolate raw input and exceptions; during account
|
||||
// creation that input includes a password. Nothing it wants to log is ever emitted.
|
||||
disabled: true,
|
||||
},
|
||||
secret: randomBytes(32).toString("base64url"),
|
||||
});
|
||||
|
||||
return async (input: BootstrapIdentityInput): Promise<void> => {
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: input.email, name: input.name, password: input.password },
|
||||
});
|
||||
} catch (error) {
|
||||
throw translateBootstrapIdentityError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user