SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
4.9 KB · 136 lines typescript
Raw Blame History
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