import { normalizeRole, type Role } from "@cms/content/rbac" import { db } 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" 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 { // Temporary fallback until registration policy is managed from admin settings. return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true" } 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 } 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, }, 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 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, }) } return } const passwordHash = await ctx.password.hash(config.password) const createdUser = await ctx.internalAdapter.createUser({ name: config.name, email: normalizedEmail, 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, name: supportName, password: supportPassword, role: "support", isHidden: true, }) } export async function ensureSupportUserBootstrap(): Promise { if (supportBootstrapPromise) { await supportBootstrapPromise return } supportBootstrapPromise = bootstrapSystemUsers() try { await supportBootstrapPromise } catch (error) { supportBootstrapPromise = null throw error } } export async function promoteFirstRegisteredUserToOwner(userId: string): Promise { return 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 }) } export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { const sessionUserRole = session?.user?.role if (typeof sessionUserRole !== "string") { return null } return normalizeRole(sessionUserRole) }