feat(auth): add better-auth core wiring for admin and db

This commit is contained in:
2026-02-10 12:42:49 +01:00
parent 3949fd2c11
commit ba8abb3b1b
30 changed files with 807 additions and 32 deletions

View File

@ -0,0 +1,25 @@
{
"name": "@cms/auth",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts"
},
"scripts": {
"lint": "biome check src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"better-auth": "^1.4.18"
},
"devDependencies": {
"@cms/config": "workspace:*",
"@biomejs/biome": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}

View File

@ -0,0 +1,7 @@
export {
type AuthSession,
auth,
authRouteHandlers,
isAdminRegistrationEnabled,
resolveRoleFromAuthSession,
} from "./server"

View File

@ -0,0 +1,84 @@
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"
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 function isAdminRegistrationEnabled(): boolean {
const value = process.env.CMS_ADMIN_REGISTRATION_ENABLED
if (value === "true") {
return true
}
if (value === "false") {
return false
}
return !isProduction
}
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,
disableSignUp: !isAdminRegistrationEnabled(),
},
user: {
additionalFields: {
role: {
type: "string",
required: true,
defaultValue: "editor",
input: false,
},
isBanned: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
},
},
})
export const authRouteHandlers = toNextJsHandler(auth)
export type AuthSession = typeof auth.$Infer.Session
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
const sessionUserRole = session?.user?.role
if (typeof sessionUserRole !== "string") {
return null
}
return normalizeRole(sessionUserRole)
}

View File

@ -0,0 +1,8 @@
{
"extends": "@cms/config/tsconfig/base",
"compilerOptions": {
"noEmit": false,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

View File

@ -10,6 +10,7 @@
"build": "tsc -p tsconfig.json",
"lint": "biome check src prisma/seed.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"db:auth:generate": "mkdir -p prisma/generated && set -a && . ../../.env && set +a && bunx @better-auth/cli@latest generate --config prisma/better-auth.config.ts --output prisma/generated/better-auth.prisma --yes",
"db:generate": "bun --env-file=../../.env prisma generate",
"db:migrate": "bun --env-file=../../.env prisma migrate dev --name init",
"db:migrate:named": "bun --env-file=../../.env prisma migrate dev",
@ -29,6 +30,7 @@
"@biomejs/biome": "latest",
"@types/node": "latest",
"@types/pg": "latest",
"better-auth": "^1.4.18",
"prisma": "latest",
"typescript": "latest"
},

View File

@ -0,0 +1,37 @@
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { db } from "../src/client"
const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001"
const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const registrationFlag = process.env.CMS_ADMIN_REGISTRATION_ENABLED
export const auth = betterAuth({
appName: "CMS Admin",
baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin,
secret: process.env.BETTER_AUTH_SECRET ?? "dev-only-change-me-for-production",
trustedOrigins: [adminOrigin, webOrigin],
database: prismaAdapter(db, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
disableSignUp: registrationFlag === "false",
},
user: {
additionalFields: {
role: {
type: "string",
required: true,
defaultValue: "editor",
input: false,
},
isBanned: {
type: "boolean",
required: true,
defaultValue: false,
input: false,
},
},
},
})

View File

@ -0,0 +1,74 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String @default("editor")
isBanned Boolean @default(false)
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}

View File

@ -0,0 +1,80 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"role" TEXT NOT NULL DEFAULT 'editor',
"isBanned" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -16,3 +16,68 @@ model Post {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String @default("editor")
isBanned Boolean @default(false)
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}