import { normalizeRole, type Role } from "@cms/content/rbac" import { db, isAdminSelfRegistrationEnabled } from "@cms/db" import { betterAuth } from "better-auth" import { prismaAdapter } from "better-auth/adapters/prisma" import { toNextJsHandler } from "better-auth/next-js" const FALLBACK_DEV_SECRET = "dev-only-change-me-for-production" const isProduction = process.env.NODE_ENV === "production" const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001" const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" const DEFAULT_SUPPORT_USERNAME = "support" const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password" const DEFAULT_SUPPORT_NAME = "Technical Support" const DEFAULT_SUPPORT_LOGIN_KEY = "support-access" const USERNAME_MAX_LENGTH = 32 function resolveAuthSecret(): string { const value = process.env.BETTER_AUTH_SECRET if (value) { return value } if (isProduction) { throw new Error("BETTER_AUTH_SECRET is required in production") } return FALLBACK_DEV_SECRET } export async function hasOwnerUser(): Promise { const ownerCount = await db.user.count({ where: { role: "owner" }, }) return ownerCount > 0 } export async function isInitialOwnerRegistrationOpen(): Promise { return !(await hasOwnerUser()) } export async function isSelfRegistrationEnabled(): Promise { return isAdminSelfRegistrationEnabled() } export async function canUserSelfRegister(): Promise { if (!(await hasOwnerUser())) { return true } return isSelfRegistrationEnabled() } export function resolveSupportLoginKey(): string { const value = process.env.CMS_SUPPORT_LOGIN_KEY if (value) { return value } if (isProduction) { throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production") } return DEFAULT_SUPPORT_LOGIN_KEY } function resolveBootstrapValue( envKey: string, fallback: string, options: { requiredInProduction?: boolean } = {}, ): string { const value = process.env[envKey] if (value) { return value } if (isProduction && options.requiredInProduction) { throw new Error(`${envKey} is required in production`) } return fallback } function normalizeUsernameCandidate(input: string | null | undefined): string | null { if (!input) { return null } const normalized = input .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^[._-]+|[._-]+$/g, "") .slice(0, USERNAME_MAX_LENGTH) if (!normalized) { return null } return normalized } function extractEmailLocalPart(email: string): string { return email.split("@")[0] ?? email } async function getAvailableUsername(base: string): Promise { const normalizedBase = normalizeUsernameCandidate(base) ?? "user" for (let suffix = 0; suffix < 1000; suffix += 1) { const candidate = suffix === 0 ? normalizedBase : `${normalizedBase}-${suffix}`.slice(0, USERNAME_MAX_LENGTH) const existing = await db.user.findUnique({ where: { username: candidate }, select: { id: true }, }) if (!existing) { return candidate } } throw new Error("Unable to allocate unique username") } export async function ensureUserUsername( userId: string, options: { preferred?: string | null | undefined fallbackEmail?: string | null | undefined fallbackName?: string | null | undefined } = {}, ): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, username: true, email: true, name: true }, }) if (!user) { return null } if (user.username) { return user.username } const baseCandidate = normalizeUsernameCandidate(options.preferred) ?? normalizeUsernameCandidate( options.fallbackEmail ? extractEmailLocalPart(options.fallbackEmail) : null, ) ?? normalizeUsernameCandidate(options.fallbackName) ?? normalizeUsernameCandidate(extractEmailLocalPart(user.email)) ?? normalizeUsernameCandidate(user.name) ?? "user" const username = await getAvailableUsername(baseCandidate) await db.user.update({ where: { id: user.id }, data: { username }, }) return username } export async function resolveEmailFromLoginIdentifier( identifier: string | null | undefined, ): Promise { const value = identifier?.trim() if (!value) { return null } if (value.includes("@")) { return value.toLowerCase() } const username = normalizeUsernameCandidate(value) if (!username) { return null } const user = await db.user.findUnique({ where: { username }, select: { email: true }, }) return user?.email ?? null } export const auth = betterAuth({ appName: "CMS Admin", baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin, secret: resolveAuthSecret(), trustedOrigins: [adminOrigin, webOrigin], database: prismaAdapter(db, { provider: "postgresql", }), emailAndPassword: { enabled: true, // Sign-up gating is handled in route layer so we can close registration // automatically after the first owner account is created. disableSignUp: false, }, user: { additionalFields: { role: { type: "string", required: true, defaultValue: "editor", input: false, }, username: { type: "string", required: false, input: false, }, isBanned: { type: "boolean", required: true, defaultValue: false, input: false, }, isSystem: { type: "boolean", required: true, defaultValue: false, input: false, }, isHidden: { type: "boolean", required: true, defaultValue: false, input: false, }, isProtected: { type: "boolean", required: true, defaultValue: false, input: false, }, }, }, }) export const authRouteHandlers = toNextJsHandler(auth) export type AuthSession = typeof auth.$Infer.Session let supportBootstrapPromise: Promise | null = null type BootstrapUserConfig = { email: string username: string name: string password: string role: Role isHidden: boolean } async function ensureCredentialUser(config: BootstrapUserConfig): Promise { const ctx = await auth.$context const normalizedEmail = config.email.toLowerCase() const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, { includeAccounts: true, }) if (existing?.user) { await db.user.update({ where: { id: existing.user.id }, data: { name: config.name, role: config.role, isBanned: false, isSystem: true, isHidden: config.isHidden, isProtected: true, }, }) const hasCredentialAccount = existing.accounts.some( (account) => account.providerId === "credential", ) if (!hasCredentialAccount) { const passwordHash = await ctx.password.hash(config.password) await ctx.internalAdapter.linkAccount({ userId: existing.user.id, providerId: "credential", accountId: existing.user.id, password: passwordHash, }) } await ensureUserUsername(existing.user.id, { preferred: config.username, fallbackEmail: existing.user.email, fallbackName: config.name, }) return } const availableUsername = await getAvailableUsername(config.username) const passwordHash = await ctx.password.hash(config.password) const createdUser = await ctx.internalAdapter.createUser({ name: config.name, email: normalizedEmail, username: availableUsername, emailVerified: true, role: config.role, isBanned: false, isSystem: true, isHidden: config.isHidden, isProtected: true, }) await ctx.internalAdapter.linkAccount({ userId: createdUser.id, providerId: "credential", accountId: createdUser.id, password: passwordHash, }) } async function bootstrapSystemUsers(): Promise { const supportUsername = resolveBootstrapValue("CMS_SUPPORT_USERNAME", DEFAULT_SUPPORT_USERNAME) const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", `${supportUsername}@cms.local`) const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, { requiredInProduction: true, }) const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME) await ensureCredentialUser({ email: supportEmail, username: supportUsername, name: supportName, password: supportPassword, role: "support", isHidden: true, }) } export async function ensureSupportUserBootstrap(): Promise { if (supportBootstrapPromise) { await supportBootstrapPromise return } supportBootstrapPromise = (async () => { await bootstrapSystemUsers() await enforceOwnerInvariant() })() try { await supportBootstrapPromise } catch (error) { supportBootstrapPromise = null throw error } } type OwnerInvariantState = { ownerId: string | null ownerCount: number repaired: boolean } export async function enforceOwnerInvariant(): Promise { return db.$transaction(async (tx) => { const owners = await tx.user.findMany({ where: { role: "owner" }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true, isProtected: true, isBanned: true }, }) if (owners.length === 0) { const candidate = await tx.user.findFirst({ where: { role: { not: "support", }, }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { id: true }, }) if (!candidate) { return { ownerId: null, ownerCount: 0, repaired: false, } } await tx.user.update({ where: { id: candidate.id }, data: { role: "owner", isProtected: true, isBanned: false, }, }) return { ownerId: candidate.id, ownerCount: 1, repaired: true, } } const canonicalOwner = owners[0] const extraOwnerIds = owners.slice(1).map((owner) => owner.id) if (extraOwnerIds.length > 0) { await tx.user.updateMany({ where: { id: { in: extraOwnerIds } }, data: { role: "admin", isProtected: false, }, }) } if (!canonicalOwner.isProtected || canonicalOwner.isBanned) { await tx.user.update({ where: { id: canonicalOwner.id }, data: { isProtected: true, isBanned: false, }, }) } return { ownerId: canonicalOwner.id, ownerCount: 1, repaired: extraOwnerIds.length > 0 || !canonicalOwner.isProtected || canonicalOwner.isBanned, } }) } export async function canDeleteUserAccount(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { role: true, isProtected: true }, }) if (!user) { return false } // Protected/system users (support + canonical owner) are never deletable // through self-service endpoints. if (user.isProtected) { return false } if (user.role !== "owner") { return true } // Defensive fallback for drifted data; normal flow should already keep one owner. const ownerCount = await db.user.count({ where: { role: "owner" }, }) return ownerCount > 1 } export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { const promoted = await db.$transaction(async (tx) => { const existingOwner = await tx.user.findFirst({ where: { role: "owner" }, select: { id: true }, }) if (existingOwner) { return false } await tx.user.update({ where: { id: userId }, data: { role: "owner", isSystem: false, isHidden: false, isProtected: true, isBanned: false, }, }) return true }) if (promoted) { await enforceOwnerInvariant() } return promoted } export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { const sessionUserRole = session?.user?.role if (typeof sessionUserRole !== "string") { return null } return normalizeRole(sessionUserRole) }