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); } }; }