import "server-only"; import { betterAuth } from "better-auth"; import { APIError } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { getDb, users, sessions, accounts, verifications, signupAllowlist, eq } from "@fetcha/db"; import { getEmailService } from "@fetcha/email"; import { ensureWorkspace } from "./workspace"; import { writeAudit } from "./audit"; import { SITE_URL } from "./utils"; import { ACCESS_DENIED_MESSAGE, isAdminEmail, normalizeEmail } from "./access"; const secret = process.env.AUTH_SECRET; if (!secret || secret.length < 32) { if (process.env.NODE_ENV === "production") throw new Error("AUTH_SECRET must be set (32+ chars)"); } export const auth = betterAuth({ appName: "Fetcha", baseURL: SITE_URL, secret: secret ?? "dev-secret-fetcha-0123456789abcdef0123456789abcdef", trustedOrigins: [SITE_URL, "http://localhost:8220"], database: drizzleAdapter(getDb(), { provider: "pg", usePlural: true, schema: { users, sessions, accounts, verifications }, }), emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 128, requireEmailVerification: false, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { await getEmailService().sendPasswordReset(user.email, url); }, onPasswordReset: async ({ user }) => { await writeAudit({ userId: user.id, action: "password.reset" }); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, expiresIn: 60 * 60 * 24, sendVerificationEmail: async ({ user, url }) => { await getEmailService().sendVerification(user.email, url); }, afterEmailVerification: async (user) => { await writeAudit({ userId: user.id, action: "email.verified" }); await getEmailService() .sendWelcome(user.email, user.name ?? "") .catch(() => {}); }, }, user: { additionalFields: { role: { type: "string", required: false, defaultValue: "user", input: false }, onboardingCompletedAt: { type: "date", required: false, input: false }, }, changeEmail: { enabled: true, sendChangeEmailVerification: async ({ user, newEmail, url }: { user: { email: string }; newEmail: string; url: string }) => { await getEmailService().sendEmailChangeApproval(user.email, url, newEmail); }, }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url }) => { await getEmailService().sendAccountDeletion(user.email, url); }, beforeDelete: async (user) => { await writeAudit({ userId: user.id, action: "account.deleted" }); }, }, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, rateLimit: { enabled: true, window: 60, max: 60, customRules: { "/sign-in/email": { window: 60, max: 10 }, "/sign-up/email": { window: 60, max: 5 }, "/forget-password": { window: 60, max: 5 }, }, }, advanced: { cookiePrefix: "fetcha", useSecureCookies: process.env.NODE_ENV === "production", }, databaseHooks: { user: { create: { /** * Invitation-only platform: refuse any email that is neither on `signup_allowlist` nor in * `ADMIN_EMAILS`. Admin emails are created with `role = "admin"`; everyone else is a plain user. */ before: async (user) => { const email = normalizeEmail(user.email); const admin = isAdminEmail(email); if (!admin) { const [row] = await getDb().select({ email: signupAllowlist.email }).from(signupAllowlist).where(eq(signupAllowlist.email, email)).limit(1); if (!row) throw new APIError("FORBIDDEN", { message: ACCESS_DENIED_MESSAGE }); } return { data: { ...user, email, role: admin ? "admin" : "user" } }; }, after: async (user) => { await ensureWorkspace({ id: user.id, name: user.name, email: user.email }); await writeAudit({ userId: user.id, action: "account.created" }); // Mark the invitation as consumed (no-op for admin emails that were never on the list). await getDb() .update(signupAllowlist) .set({ usedAt: new Date(), userId: user.id }) .where(eq(signupAllowlist.email, normalizeEmail(user.email))) .catch((e: unknown) => console.error("[auth] allowlist update failed", (e as Error).message)); }, }, }, session: { create: { after: async (session) => { await writeAudit({ userId: session.userId, action: "login", ipAddress: session.ipAddress ?? null, userAgent: session.userAgent ?? null }); }, }, }, }, plugins: [nextCookies()], }); export type Session = typeof auth.$Infer.Session; export type AuthUser = Session["user"];