TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import "server-only";2import { betterAuth } from "better-auth";3import { APIError } from "better-auth/api";4import { drizzleAdapter } from "better-auth/adapters/drizzle";5import { nextCookies } from "better-auth/next-js";6import { getDb, users, sessions, accounts, verifications, signupAllowlist, eq } from "@fetcha/db";7import { getEmailService } from "@fetcha/email";8import { ensureWorkspace } from "./workspace";9import { writeAudit } from "./audit";10import { SITE_URL } from "./utils";11import { ACCESS_DENIED_MESSAGE, isAdminEmail, normalizeEmail } from "./access";1213const secret = process.env.AUTH_SECRET;14if (!secret || secret.length < 32) {15 if (process.env.NODE_ENV === "production") throw new Error("AUTH_SECRET must be set (32+ chars)");16}1718export const auth = betterAuth({19 appName: "Fetcha",20 baseURL: SITE_URL,21 secret: secret ?? "dev-secret-fetcha-0123456789abcdef0123456789abcdef",22 trustedOrigins: [SITE_URL, "http://localhost:8220"],23 database: drizzleAdapter(getDb(), {24 provider: "pg",25 usePlural: true,26 schema: { users, sessions, accounts, verifications },27 }),28 emailAndPassword: {29 enabled: true,30 minPasswordLength: 10,31 maxPasswordLength: 128,32 requireEmailVerification: false,33 revokeSessionsOnPasswordReset: true,34 sendResetPassword: async ({ user, url }) => {35 await getEmailService().sendPasswordReset(user.email, url);36 },37 onPasswordReset: async ({ user }) => {38 await writeAudit({ userId: user.id, action: "password.reset" });39 },40 },41 emailVerification: {42 sendOnSignUp: true,43 autoSignInAfterVerification: true,44 expiresIn: 60 * 60 * 24,45 sendVerificationEmail: async ({ user, url }) => {46 await getEmailService().sendVerification(user.email, url);47 },48 afterEmailVerification: async (user) => {49 await writeAudit({ userId: user.id, action: "email.verified" });50 await getEmailService()51 .sendWelcome(user.email, user.name ?? "")52 .catch(() => {});53 },54 },55 user: {56 additionalFields: {57 role: { type: "string", required: false, defaultValue: "user", input: false },58 onboardingCompletedAt: { type: "date", required: false, input: false },59 },60 changeEmail: {61 enabled: true,62 sendChangeEmailVerification: async ({ user, newEmail, url }: { user: { email: string }; newEmail: string; url: string }) => {63 await getEmailService().sendEmailChangeApproval(user.email, url, newEmail);64 },65 },66 deleteUser: {67 enabled: true,68 sendDeleteAccountVerification: async ({ user, url }) => {69 await getEmailService().sendAccountDeletion(user.email, url);70 },71 beforeDelete: async (user) => {72 await writeAudit({ userId: user.id, action: "account.deleted" });73 },74 },75 },76 session: {77 expiresIn: 60 * 60 * 24 * 30,78 updateAge: 60 * 60 * 24,79 cookieCache: { enabled: true, maxAge: 5 * 60 },80 },81 rateLimit: {82 enabled: true,83 window: 60,84 max: 60,85 customRules: {86 "/sign-in/email": { window: 60, max: 10 },87 "/sign-up/email": { window: 60, max: 5 },88 "/forget-password": { window: 60, max: 5 },89 },90 },91 advanced: {92 cookiePrefix: "fetcha",93 useSecureCookies: process.env.NODE_ENV === "production",94 },95 databaseHooks: {96 user: {97 create: {98 /**99 * Invitation-only platform: refuse any email that is neither on `signup_allowlist` nor in100 * `ADMIN_EMAILS`. Admin emails are created with `role = "admin"`; everyone else is a plain user.101 */102 before: async (user) => {103 const email = normalizeEmail(user.email);104 const admin = isAdminEmail(email);105 if (!admin) {106 const [row] = await getDb().select({ email: signupAllowlist.email }).from(signupAllowlist).where(eq(signupAllowlist.email, email)).limit(1);107 if (!row) throw new APIError("FORBIDDEN", { message: ACCESS_DENIED_MESSAGE });108 }109 return { data: { ...user, email, role: admin ? "admin" : "user" } };110 },111 after: async (user) => {112 await ensureWorkspace({ id: user.id, name: user.name, email: user.email });113 await writeAudit({ userId: user.id, action: "account.created" });114 // Mark the invitation as consumed (no-op for admin emails that were never on the list).115 await getDb()116 .update(signupAllowlist)117 .set({ usedAt: new Date(), userId: user.id })118 .where(eq(signupAllowlist.email, normalizeEmail(user.email)))119 .catch((e: unknown) => console.error("[auth] allowlist update failed", (e as Error).message));120 },121 },122 },123 session: {124 create: {125 after: async (session) => {126 await writeAudit({ userId: session.userId, action: "login", ipAddress: session.ipAddress ?? null, userAgent: session.userAgent ?? null });127 },128 },129 },130 },131 plugins: [nextCookies()],132});133134export type Session = typeof auth.$Infer.Session;135export type AuthUser = Session["user"];136