523 lines
12 KiB
TypeScript
523 lines
12 KiB
TypeScript
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<boolean> {
|
|
const ownerCount = await db.user.count({
|
|
where: { role: "owner" },
|
|
})
|
|
|
|
return ownerCount > 0
|
|
}
|
|
|
|
export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
|
|
return !(await hasOwnerUser())
|
|
}
|
|
|
|
export async function isSelfRegistrationEnabled(): Promise<boolean> {
|
|
return isAdminSelfRegistrationEnabled()
|
|
}
|
|
|
|
export async function canUserSelfRegister(): Promise<boolean> {
|
|
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<string> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<void> | null = null
|
|
|
|
type BootstrapUserConfig = {
|
|
email: string
|
|
username: string
|
|
name: string
|
|
password: string
|
|
role: Role
|
|
isHidden: boolean
|
|
}
|
|
|
|
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<OwnerInvariantState> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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)
|
|
}
|